08QR code share popup
Press share-by-QR and a popup appears; the code inside is not an image file but a 29-cell module matrix, so a phone camera scan returns the address.
Best forBridging booths, business cards, and offline signs to the web
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">Share by QR</span>
</button>
<div class="qp__pop" id="qp-pop" role="dialog" aria-label="Share with a QR code">
<div class="qp__code">
<div class="qp__grid" role="img" aria-label="QR code holding the URL"></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>Point a camera at it to open</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 code share popup — Measured area 13.812% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · Social Share Buttons: 9 Copy-Paste UI Patterns
The post this demo lives in
Social Share Buttons: 9 Copy-Paste UI PatternsSocial share buttons are the last door a page leaves through, and the one tap a fake demo gives away. The nine here real See all demos in this post
If you like this effect - similar demos
Interactions04 Two-button split — a row where primary and secondary share the success state in stagger9 Copy Button CSS UI Patterns (Copy to Clipboard)
Interactions06 Twin Bubbles That Join and Share One Curved Wall Between ThemBubble Animation CSS: 9 Soap-Film Scenes That Pop
Interactions01 Cursor shape morph9 Mouse Effects CSS Tricks You Can Copy-Paste