08QR 코드 공유 팝업
QR 으로 공유 버튼을 누르면 팝업이 뜨고 그 안의 코드는 이미지 파일이 아니라 29칸 격자의 모듈 행렬이라 휴대폰 카메라로 스캔하면 주소가 나옵니다.
쓰임부스·명함·오프라인 간판에서 온라인으로 잇는 자리
role=dialogtranslateY 스캔 라인scale 팝업

<div class="stage">
<section class="qp">
<button class="qp__open" type="button" aria-expanded="true" aria-controls="qp-pop">
<span class="qp__ic"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M112,56v48a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h48A8,8,0,0,1,112,56Zm-8,88H56a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V152A8,8,0,0,0,104,144Zm96-96H152a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V56A8,8,0,0,0,200,48Z" opacity="0.2"/><path d="M104,40H56A16,16,0,0,0,40,56v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,104,40Zm0,64H56V56h48v48Zm0,32H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm0,64H56V152h48v48ZM200,40H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,64H152V56h48v48Zm-64,72V144a8,8,0,0,1,16,0v32a8,8,0,0,1-16,0Zm80-16a8,8,0,0,1-8,8H184v40a8,8,0,0,1-8,8H144a8,8,0,0,1,0-16h24V144a8,8,0,0,1,16,0v8h24A8,8,0,0,1,216,160Zm0,32v16a8,8,0,0,1-16,0V192a8,8,0,0,1,16,0Z"/></svg></span>
<span class="qp__openName">QR 로 공유</span>
</button>
<div class="qp__pop" id="qp-pop" role="dialog" aria-label="QR 코드로 공유">
<div class="qp__code">
<div class="qp__grid" role="img" aria-label="주소를 담은 QR 코드"></div>
<span class="qp__scan" aria-hidden="true"></span>
</div>
<p class="qp__hint">
<span class="qp__hintIc" aria-hidden="true"><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M192,56V200H64V56Z" opacity="0.2"/><path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z"/></svg></span>
<span>카메라로 비추면 열립니다</span>
</p>
</div>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-mist: #bdb7c6;
$sh-sky: #c8dce4; // 잉크 위 강조 11.09:1
$duration: $dur-loop; // 팝업이 서고 스캔 선이 훑는 한 바퀴
$easing: $ease-pop;
$color: $sh-sky; // 스캔 선 색
@include stage($sh-ink);
.qp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: clamp(232px, 72vw, 288px);
padding: $sp-3;
border-radius: $r-card;
background: $sh-ink2;
box-shadow: $shadow-raised;
}
.qp__open {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $color;
color: $sh-ink; // 하늘색 면 위 잉크 글자 11.09:1
cursor: pointer;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.qp__open:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: 2px;
}
.qp__ic { display: grid; place-items: center; }
.qp__ic svg { display: block; }
.qp__pop {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
transform: scale(1);
opacity: 1;
transition: transform $dur-base $easing, opacity $dur-quick $easing;
}
// 손으로 쓰는 경로 — 버튼을 다시 누르면 팝업이 접힌다
.qp.is-closed .qp__pop {
transform: scale(.8);
opacity: 0;
pointer-events: none;
}
.qp__code {
position: relative;
box-sizing: border-box;
padding: $sp-2;
border-radius: $r-control;
background: $sh-paper;
overflow: hidden;
}
// 코드는 이미지가 아니라 29칸 격자다 — 칸 하나가 모듈 하나다
.qp__grid {
display: grid;
grid-template-columns: repeat(var(--cells, 29), 1fr);
width: 112px;
height: 112px;
}
.qp__cell { background: $sh-paper; }
.qp__cell.is-on { background: $sh-ink; }
// 스캔 선 — 코드 위를 위아래로 훑는다. 코드 자체는 건드리지 않아 스캔이 계속 된다
.qp__scan {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 18px;
background: linear-gradient(180deg, rgba(127, 212, 245, 0), rgba(127, 212, 245, .72));
transform: translateY(0);
opacity: 0; // 미리보기 루프에서만 보인다 — 손으로 볼 때는 코드만 남는다
}
.is-demo .qp__scan { opacity: 1; }
.qp__hint {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
color: $sh-mist; // 잉크 위 보조 글자 9.43:1
font-weight: 600; font-size: 11px; line-height: 1.2;
font-family: inherit;
}
.qp__hintIc { display: grid; place-items: center; color: $color; }
.qp__hintIc svg { display: block; }
// ── 자동 재생 ───────────────────────────────────────────────
// 팝업은 루프 안에서 닫히지 않는다 — 닫힘을 재현하면 코드 없는 장이 대표 이미지로 뽑힌다.
// 되돌림은 루프 경계에서만 일어난다
.is-demo .qp__pop {
animation-name: qpRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 스캔 선이 훑으며 프레임을 채운다 — 팝업 등장만으로는 움직인 프레임이 너무 적다
.is-demo .qp__scan {
animation-name: qpScan;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes qpRise {
0% { transform: scale(.8); opacity: 0; }
18% { transform: scale(1.04); opacity: 1; }
26%, 100% { transform: scale(1); opacity: 1; }
}
// 128px 은 코드 상자의 안쪽 높이(112px 격자 + 위아래 여백 8px 씩)에서 선 높이를 뺀 값이다
@keyframes qpScan {
0%, 26% { transform: translateY(0); }
62% { transform: translateY(110px); }
90%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.qp { gap: $sp-2; padding: $sp-2; }
.qp__open { height: 32px; padding: 0 $sp-2; font-size: 11px; }
.qp__ic svg { width: 18px; height: 18px; }
.qp__code { padding: $sp-1; }
.qp__grid { width: 84px; height: 84px; }
.qp__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .qp__pop, .is-demo .qp__scan { animation: none !important; }
.qp__pop { transition: none !important; }
// 움직임을 끊어도 코드는 그대로 서 있고 스캔 선만 사라진다
.qp__scan { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #15131a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.qp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: clamp(232px, 72vw, 288px);
padding: 12px;
border-radius: 16px;
background: #26232e;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.qp__open {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #c8dce4;
color: #15131a;
cursor: pointer;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.qp__open:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.qp__ic {
display: grid;
place-items: center;
}
.qp__ic svg {
display: block;
}
.qp__pop {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
transform: scale(1);
opacity: 1;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.qp.is-closed .qp__pop {
transform: scale(0.8);
opacity: 0;
pointer-events: none;
}
.qp__code {
position: relative;
box-sizing: border-box;
padding: 8px;
border-radius: 12px;
background: #ffffff;
overflow: hidden;
}
.qp__grid {
display: grid;
grid-template-columns: repeat(var(--cells, 29), 1fr);
width: 112px;
height: 112px;
}
.qp__cell {
background: #ffffff;
}
.qp__cell.is-on {
background: #15131a;
}
.qp__scan {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 18px;
background: linear-gradient(180deg, rgba(127, 212, 245, 0), rgba(127, 212, 245, 0.72));
transform: translateY(0);
opacity: 0;
}
.is-demo .qp__scan {
opacity: 1;
}
.qp__hint {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: #bdb7c6;
font-weight: 600;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
}
.qp__hintIc {
display: grid;
place-items: center;
color: #c8dce4;
}
.qp__hintIc svg {
display: block;
}
.is-demo .qp__pop {
animation-name: qpRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .qp__scan {
animation-name: qpScan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes qpRise {
0% {
transform: scale(0.8);
opacity: 0;
}
18% {
transform: scale(1.04);
opacity: 1;
}
26%, 100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes qpScan {
0%, 26% {
transform: translateY(0);
}
62% {
transform: translateY(110px);
}
90%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.qp {
gap: 8px;
padding: 8px;
}
.qp__open {
height: 32px;
padding: 0 8px;
font-size: 11px;
}
.qp__ic svg {
width: 18px;
height: 18px;
}
.qp__code {
padding: 4px;
}
.qp__grid {
width: 84px;
height: 84px;
}
.qp__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .qp__pop, .is-demo .qp__scan {
animation: none !important;
}
.qp__pop {
transition: none !important;
}
.qp__scan {
opacity: 0;
}
}(function () {
var body = document.body;
var grid = document.querySelector('.qp__grid');
var open = document.querySelector('.qp__open');
var root = document.querySelector('.qp');
// 29×29 모듈 행렬. 이미지 파일이 아니라 검은 칸 좌표라 확대해도 안 뭉개진다
var rows = ["00000000000000000000000000000", "00000000000000000000000000000", "00111111101100010010111111100", "00100000100000010000100000100", "00101110101001111000101110100", "00101110101101011110101110100", "00101110101111000110101110100", "00100000100100100000100000100", "00111111101010101010111111100", "00000000000101001100000000000", "00111100101000001001001110100", "00001001010100011010010001000", "00100110110000001000110000000", "00111010001001111011100110000", "00110000111101010001101011100", "00010001000011011111111000100", "00010111111100101001001011000", "00100101001000100000111000100", "00111000110111010011111111100", "00000000001010110010001010100", "00111111100001110010101011100", "00100000100000100110001001000", "00101110100001001011111100100", "00101110101100110001101111100", "00101110101001110010101011000", "00100000101010111000001010000", "00111111101101111000011111100", "00000000000000000000000000000", "00000000000000000000000000000"];
grid.style.setProperty('--cells', rows.length);
var cells = document.createDocumentFragment();
rows.forEach(function (line) {
line.split('').forEach(function (bit) {
var cell = document.createElement('span');
cell.className = bit === '1' ? 'qp__cell is-on' : 'qp__cell';
cells.appendChild(cell);
});
});
grid.appendChild(cells);
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
open.addEventListener('click', function () {
hand();
var on = open.getAttribute('aria-expanded') !== 'true';
open.setAttribute('aria-expanded', on ? 'true' : 'false');
root.classList.toggle('is-closed', !on);
});
open.addEventListener('pointerdown', hand);
open.addEventListener('focusin', hand);
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShareQrPopup.module.scss";
export type ShareQrPopupProps = {
/** 29×29 모듈 행렬. "1" 이 검은 칸이다 */
rows?: string[];
/** 팝업이 서고 스캔 선이 훑는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 스캔 선 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type GridVars = CSSProperties & { "--cells"?: number };
/* https://godrichstory.com 을 담은 모듈 행렬 — 이미지 파일이 아니라 검은 칸 좌표다 */
const QR_ROWS: string[] = ["00000000000000000000000000000", "00000000000000000000000000000", "00111111101100010010111111100", "00100000100000010000100000100", "00101110101001111000101110100", "00101110101101011110101110100", "00101110101111000110101110100", "00100000100100100000100000100", "00111111101010101010111111100", "00000000000101001100000000000", "00111100101000001001001110100", "00001001010100011010010001000", "00100110110000001000110000000", "00111010001001111011100110000", "00110000111101010001101011100", "00010001000011011111111000100", "00010111111100101001001011000", "00100101001000100000111000100", "00111000110111010011111111100", "00000000001010110010001010100", "00111111100001110010101011100", "00100000100000100110001001000", "00101110100001001011111100100", "00101110101100110001101111100", "00101110101001110010101011000", "00100000101010111000001010000", "00111111101101111000011111100", "00000000000000000000000000000", "00000000000000000000000000000"];
export default function ShareQrPopup({
rows = QR_ROWS,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#7fd4f5",
autoPlay = true,
}: ShareQrPopupProps) {
const [demo, setDemo] = useState(autoPlay);
const [open, setOpen] = useState(true);
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyToggle() {
stopDemo();
setOpen((prev) => !prev);
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const cells: string[] = [];
rows.forEach((line) => line.split("").forEach((bit) => cells.push(bit)));
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section className={styles.qr + (open ? "" : " " + styles.isClosed)}>
<button
className={styles.open}
type="button"
aria-expanded={open}
onClick={applyToggle}
onPointerDown={stopDemo}
onFocus={stopDemo}
>
<span className={styles.ic}><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M112,56v48a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h48A8,8,0,0,1,112,56Zm-8,88H56a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V152A8,8,0,0,0,104,144Zm96-96H152a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V56A8,8,0,0,0,200,48Z" opacity="0.2"/><path d="M104,40H56A16,16,0,0,0,40,56v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,104,40Zm0,64H56V56h48v48Zm0,32H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm0,64H56V152h48v48ZM200,40H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,64H152V56h48v48Zm-64,72V144a8,8,0,0,1,16,0v32a8,8,0,0,1-16,0Zm80-16a8,8,0,0,1-8,8H184v40a8,8,0,0,1-8,8H144a8,8,0,0,1,0-16h24V144a8,8,0,0,1,16,0v8h24A8,8,0,0,1,216,160Zm0,32v16a8,8,0,0,1-16,0V192a8,8,0,0,1,16,0Z"/></svg></span>
<span className={styles.openName}>QR 로 공유</span>
</button>
<div className={styles.pop} role="dialog" aria-label="QR 코드로 공유">
<div className={styles.code}>
<div
className={styles.grid}
role="img"
aria-label="주소를 담은 QR 코드"
style={{ "--cells": rows.length } as GridVars}
>
{cells.map((bit, i) => (
<span key={i} className={styles.cell + (bit === "1" ? " " + styles.isOn : "")} />
))}
</div>
<span className={styles.scan} aria-hidden="true" />
</div>
<p className={styles.hint}>
<span className={styles.hintIc} aria-hidden="true"><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M192,56V200H64V56Z" opacity="0.2"/><path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z"/></svg></span>
<span>카메라로 비추면 열립니다</span>
</p>
</div>
</section>
</div>
);
}08 QR 코드 공유 팝업 — 측정 누적면적 13.812% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는
이 데모가 속한 글
공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는공유하기 버튼은 방문자가 누른 한 번으로 글을 밖으로 내보내는 마지막 문입니다. 여기 모은 아홉 가지는 겉만 그려 둔 게 아니라 누르면 진짜 복사가 되는 공유 UI 를 HTML·CSS·자바스크립트로 짜 둔 것이라, 이 글의 데모 전부 보기


