Social Share Buttons: 9 Copy-Paste UI Patterns
Social share buttons are the last door a page leaves through, and the one tap a fake demo gives away.
01 share-inline-row
02 share-floating-side-bar
03 share-copy-link-toast
04 share-count-badge
06 share-native-mobile-sheet
07 share-sticky-bottom-mobile
08 share-qr-code-popup
09 share-email-embed
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Inline share row
- 02 Floating side share rail
- 03 Copy-link toast
- 04 Share-count count-up
- 05 Expanding icon share button
- 06 Mobile share sheet
- 07 Sticky bottom share bar
- 08 QR code share popup
- 09 Email and embed share box
The nine social share buttons html patterns are ordered by when a visitor meets them, not by what is trendy. They start with the inline row found at the end of an article (01), move to the rail that rides beside the text while they read (02) and the toast that answers the moment they press copy (03). After the share-count badges that show proof (04) come the two ways to save space — an icon that fans out of a single button (05) and a bar pinned to the bottom (07) — while phones get the sheet that rises from below (06). The last two close the loop: a QR popup that bridges offline paper (08) and a box that lets the sender pick mail or embed (09). All nine share the same ink #15131a, cream #fdf8f0, and sky #7fd4f5, so mixing two or three never drifts out of palette. Returning the visitor to their place afterward is breadcrumb UI's job, and finishing the job without leaving the keys is the axis keyboard shortcut UI owns.
01Inline share row
What this share button ui design shows
A label and four round 44px icon buttons in one row at the bottom of the card — the shape everyone builds first. On hover and focus a button lifts 4px while a circle laid over it fills from scale(.72) to scale(1), and 44px is the minimum fingertip target.

<div class="stage">
<section class="ir" aria-label="Share post">
<p class="ir__title">This week's demo magazine</p>
<span class="ir__row ir__row--full" aria-hidden="true"></span>
<span class="ir__row ir__row--wide" aria-hidden="true"></span>
<span class="ir__row ir__row--mid" aria-hidden="true"></span>
<div class="ir__share">
<p class="ir__label">Share this post</p>
<div class="ir__btns" role="group" aria-label="Share options">
<button class="ir__btn" type="button" aria-pressed="false" aria-label="Copy link">
<span class="ir__ring" aria-hidden="true"></span>
<span class="ir__ic"><svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></span>
</button>
<button class="ir__btn" type="button" aria-pressed="false" aria-label="Send by email">
<span class="ir__ring" aria-hidden="true"></span>
<span class="ir__ic"><svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
</button>
<button class="ir__btn" type="button" aria-pressed="false" aria-label="Send in a message">
<span class="ir__ring" aria-hidden="true"></span>
<span class="ir__ic"><svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
</button>
<button class="ir__btn" type="button" aria-pressed="false" aria-label="Show QR code">
<span class="ir__ring" aria-hidden="true"></span>
<span class="ir__ic"><svg width="22" height="22" 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>
</button>
</div>
</div>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 — 아홉 개가 같은 값을 쓴다.
// 대비 실측(WCAG 2.1 상대 휘도): ink on paper 18.42:1 · slate on paper 7.15:1
// paper on ink 18.42:1 · sky on ink 11.09:1 · ink on sky 11.09:1
$sh-ink: #15131a; // 잉크 무대 / 밝은 면 위 글자
$sh-ink2: #26232e; // 잉크 위 카드·구분
$sh-paper: #ffffff;
$sh-cream: #fdf8f0; // 밝은 바탕
$sh-slate: #5b5566; // 밝은 면 위 보조 글자
$sh-mist: #bdb7c6; // 잉크 위 보조 글자
$sh-amber: $stage-yellow;
$sh-flame: $stage-orange;
$sh-sky: #c8dce4; // 잉크 위 강조 (밝은 하늘)
$sh-deep: #477690; // 밝은 면 위 강조
$duration: $dur-loop; // 버튼 넷이 차례로 들리는 한 바퀴
$easing: $ease-spring; // 들림·링 차오름
$color: $sh-deep; // 누른 버튼의 면 색
@include stage($sh-paper);
.ir {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: clamp(272px, 84vw, 376px);
padding: $sp-4;
border-radius: $r-card;
background: $sh-cream;
box-shadow: $shadow-raised;
}
.ir__title {
margin: 0;
color: $sh-ink;
font-weight: 700; font-size: 15px; line-height: 1.3;
font-family: inherit;
}
// 본문 자리표시 줄 — 장식이라 aria-hidden
.ir__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: #e3ddd2;
}
.ir__row--full { width: 100%; }
.ir__row--wide { width: 88%; }
.ir__row--mid { width: 62%; }
.ir__share {
display: flex;
flex-direction: column;
gap: $sp-2;
margin-top: $sp-2;
padding-top: $sp-3;
border-top: 1px solid #e3ddd2;
}
.ir__label {
margin: 0;
color: $sh-slate; // 크림 위 보조 글자 6.76:1
font-weight: 600; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ir__btns {
display: flex;
gap: $sp-3;
}
.ir__btn {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
width: 44px; // 손가락이 닿는 최소 크기
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: $sh-paper;
color: $sh-ink;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $easing, background-color $dur-quick $easing, color $dur-quick $easing;
}
// 링은 테두리가 아니라 겹쳐 둔 원 — 커져도 레이아웃을 다시 계산하지 않는다
.ir__ring {
position: absolute;
inset: 0;
border: 2px solid $color;
border-radius: 50%;
transform: scale(.72);
opacity: 0;
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.ir__ic { display: grid; place-items: center; }
.ir__ic svg { display: block; }
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 상태가 전부 보인다
.ir__btn:hover,
.ir__btn:focus-visible {
transform: translateY(-4px);
}
.ir__btn:hover .ir__ring,
.ir__btn:focus-visible .ir__ring {
transform: scale(1);
opacity: 1;
}
.ir__btn:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
.ir__btn[aria-pressed="true"] {
background: $color; // 딥블루 면 위 흰 아이콘 6.94:1
color: $sh-paper;
}
.ir__btn[aria-pressed="true"] .ir__ring { opacity: 0; }
// ── 자동 재생 ───────────────────────────────────────────────
// 버튼 넷이 차례로 들린다. animation 단축을 쓰면 delay 가 0 으로 리셋되므로 낱개 속성으로,
// 지연 구간에 기본 상태가 한 프레임 번쩍이지 않게 fill-mode: backwards 를 같이 준다
.is-demo .ir__btn {
animation-name: irLift;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ir__ring {
animation-name: irRing;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ir__btn:nth-child(1), .is-demo .ir__btn:nth-child(1) .ir__ring { animation-delay: 0s; }
.is-demo .ir__btn:nth-child(2), .is-demo .ir__btn:nth-child(2) .ir__ring { animation-delay: .18s; }
.is-demo .ir__btn:nth-child(3), .is-demo .ir__btn:nth-child(3) .ir__ring { animation-delay: .36s; }
.is-demo .ir__btn:nth-child(4), .is-demo .ir__btn:nth-child(4) .ir__ring { animation-delay: .54s; }
// 들림 6% · 앉음 14% 의 비대칭 — 물러남이 더 빠르면 아무도 안 들린 장이 대표로 뽑힌다
@keyframes irLift {
0% { transform: translateY(0); }
6%, 22% { transform: translateY(-6px); }
36%, 100% { transform: translateY(0); }
}
@keyframes irRing {
0% { transform: scale(.72); opacity: 0; }
6%, 22% { transform: scale(1); opacity: 1; }
36%, 100% { transform: scale(.72); opacity: 0; }
}
@media (max-width: 340px), (max-height: 220px) {
.ir { gap: $sp-1; padding: $sp-3; }
.ir__title { font-size: 13px; }
.ir__row { height: 6px; }
.ir__share { margin-top: 0; padding-top: $sp-2; }
.ir__btns { gap: $sp-2; }
.ir__btn { width: 40px; height: 40px; }
.ir__ic svg { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ir__btn, .is-demo .ir__ring { animation: none !important; }
.ir__btn, .ir__ring { transition: none !important; }
// 움직임을 끊어도 호버·초점의 링은 그대로 보인다
.ir__btn:hover .ir__ring, .ir__btn:focus-visible .ir__ring { opacity: 1; transform: scale(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ir {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: clamp(272px, 84vw, 376px);
padding: 16px;
border-radius: 16px;
background: #fdf8f0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ir__title {
margin: 0;
color: #15131a;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.ir__row {
display: block;
height: 8px;
border-radius: 999px;
background: #e3ddd2;
}
.ir__row--full {
width: 100%;
}
.ir__row--wide {
width: 88%;
}
.ir__row--mid {
width: 62%;
}
.ir__share {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid #e3ddd2;
}
.ir__label {
margin: 0;
color: #5b5566;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ir__btns {
display: flex;
gap: 12px;
}
.ir__btn {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: #ffffff;
color: #15131a;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ir__ring {
position: absolute;
inset: 0;
border: 2px solid #477690;
border-radius: 50%;
transform: scale(0.72);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ir__ic {
display: grid;
place-items: center;
}
.ir__ic svg {
display: block;
}
.ir__btn:hover,
.ir__btn:focus-visible {
transform: translateY(-4px);
}
.ir__btn:hover .ir__ring,
.ir__btn:focus-visible .ir__ring {
transform: scale(1);
opacity: 1;
}
.ir__btn:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.ir__btn[aria-pressed=true] {
background: #477690;
color: #ffffff;
}
.ir__btn[aria-pressed=true] .ir__ring {
opacity: 0;
}
.is-demo .ir__btn {
animation-name: irLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ir__ring {
animation-name: irRing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ir__btn:nth-child(1), .is-demo .ir__btn:nth-child(1) .ir__ring {
animation-delay: 0s;
}
.is-demo .ir__btn:nth-child(2), .is-demo .ir__btn:nth-child(2) .ir__ring {
animation-delay: 0.18s;
}
.is-demo .ir__btn:nth-child(3), .is-demo .ir__btn:nth-child(3) .ir__ring {
animation-delay: 0.36s;
}
.is-demo .ir__btn:nth-child(4), .is-demo .ir__btn:nth-child(4) .ir__ring {
animation-delay: 0.54s;
}
@keyframes irLift {
0% {
transform: translateY(0);
}
6%, 22% {
transform: translateY(-6px);
}
36%, 100% {
transform: translateY(0);
}
}
@keyframes irRing {
0% {
transform: scale(0.72);
opacity: 0;
}
6%, 22% {
transform: scale(1);
opacity: 1;
}
36%, 100% {
transform: scale(0.72);
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ir {
gap: 4px;
padding: 12px;
}
.ir__title {
font-size: 13px;
}
.ir__row {
height: 6px;
}
.ir__share {
margin-top: 0;
padding-top: 8px;
}
.ir__btns {
gap: 8px;
}
.ir__btn {
width: 40px;
height: 40px;
}
.ir__ic svg {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ir__btn, .is-demo .ir__ring {
animation: none !important;
}
.ir__btn, .ir__ring {
transition: none !important;
}
.ir__btn:hover .ir__ring, .ir__btn:focus-visible .ir__ring {
opacity: 1;
transform: scale(1);
}
}(function () {
var body = document.body;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
document.querySelectorAll('.ir__btn').forEach(function (b) {
b.addEventListener('pointerdown', hand);
b.addEventListener('focusin', hand);
b.addEventListener('click', function () {
hand();
var on = b.getAttribute('aria-pressed') === 'true'; // 누른 자국이 그대로 남는다
b.setAttribute('aria-pressed', on ? 'false' : 'true');
});
});
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareInlineRow.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type ClickEvt = { preventDefault(): void };
export type ShareInlineRowProps = {
/** 버튼 넷의 접근성 이름 */
labels?: string[];
/** 버튼 넷이 차례로 들리는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 누른 버튼의 면 색 */
color?: string;
/** 격자 미리보기용 자동 재생 */
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · QR */
const ICONS: ReactNode[] = [
<svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="22" height="22" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<svg width="22" height="22" 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>,
];
export default function ShareInlineRow({
labels = ["링크 복사", "이메일로 보내기", "메시지로 보내기", "QR 코드 보기"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0f5f8a",
autoPlay = true,
}: ShareInlineRowProps) {
const [picked, setPicked] = useState<number[]>([]);
const [demo, setDemo] = useState(autoPlay);
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyPick(i: number, e: ClickEvt) {
e.preventDefault();
stopDemo();
setPicked((prev) => (prev.indexOf(i) >= 0 ? prev.filter((k) => k !== i) : prev.concat(i)));
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section className={styles.inline} aria-label="글 공유">
<p className={styles.title}>이번 주 데모 잡지</p>
<span className={styles.row + " " + styles.rowFull} aria-hidden="true" />
<span className={styles.row + " " + styles.rowWide} aria-hidden="true" />
<span className={styles.row + " " + styles.rowMid} aria-hidden="true" />
<div className={styles.share}>
<p className={styles.label}>이 글 공유하기</p>
<div className={styles.btns} role="group" aria-label="공유 방법">
{labels.map((name, i) => (
<button
key={name}
className={styles.btn}
type="button"
aria-pressed={picked.indexOf(i) >= 0}
aria-label={name}
onClick={(e: ClickEvt) => applyPick(i, e)}
onPointerDown={stopDemo}
onFocus={stopDemo}
>
<span className={styles.ring} aria-hidden="true" />
<span className={styles.ic}>{ICONS[i]}</span>
</button>
))}
</div>
</div>
</section>
</div>
);
}01 · Inline share row — Measured area 4.712% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A pressed button stays pressed via aria-pressed, showing what was picked.
/* The ring is a stacked circle, not a border — scaling it never relayouts */
.ir__ring {
position: absolute;
inset: 0;
border: 2px solid $color;
border-radius: 50%;
transform: scale(.72);
opacity: 0;
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.ir__btn:focus-visible .ir__ring {
transform: scale(1);
opacity: 1;
}
.ir__btn[aria-pressed="true"] {
background: $color;
color: $sh-paper;
}
02Floating side share rail
What happens on screen
On magazine-style sites with long articles, the rail sits vertically beside the text and follows the scroll with position: sticky. Buttons keep their labels hidden until hover or focus unfolds them sideways — the unfolding reveals a right side kept cut off by clip-path: inset(0 100px 0 0 round 999px), so layout never recalculates.

<div class="stage">
<div class="sr">
<div class="sr__view">
<div class="sr__doc">
<nav class="sr__rail" aria-label="Share rail">
<a class="sr__link" href="#">
<span class="sr__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="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></span>
<span class="sr__name">Copy link</span>
</a>
<a class="sr__link" href="#">
<span class="sr__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="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
<span class="sr__name">Send mail</span>
</a>
<a class="sr__link" href="#">
<span class="sr__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="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="sr__name">Message</span>
</a>
<a class="sr__link" href="#">
<span class="sr__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="M208,200a32,32,0,1,1-32-32A32,32,0,0,1,208,200ZM176,88a32,32,0,1,0-32-32A32,32,0,0,0,176,88Z" opacity="0.2"/><path d="M176,160a39.89,39.89,0,0,0-28.62,12.09l-46.1-29.63a39.8,39.8,0,0,0,0-28.92l46.1-29.63a40,40,0,1,0-8.66-13.45l-46.1,29.63a40,40,0,1,0,0,55.82l46.1,29.63A40,40,0,1,0,176,160Zm0-128a24,24,0,1,1-24,24A24,24,0,0,1,176,32ZM64,152a24,24,0,1,1,24-24A24,24,0,0,1,64,152Zm112,72a24,24,0,1,1,24-24A24,24,0,0,1,176,224Z"/></svg></span>
<span class="sr__name">More</span>
</a>
</nav>
<article class="sr__body">
<h2 class="sr__title">Long reads get shared from the side</h2>
<span class="sr__row sr__row--full" aria-hidden="true"></span>
<span class="sr__row sr__row--wide" aria-hidden="true"></span>
<span class="sr__row sr__row--mid" aria-hidden="true"></span>
<span class="sr__row sr__row--full" aria-hidden="true"></span>
<span class="sr__row sr__row--wide" aria-hidden="true"></span>
<span class="sr__row sr__row--short" aria-hidden="true"></span>
<span class="sr__row sr__row--full" aria-hidden="true"></span>
<span class="sr__row sr__row--mid" aria-hidden="true"></span>
</article>
</div>
</div>
</div>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-mist: #bdb7c6; // 잉크 위 보조 글자 9.43:1
$sh-sky: #c8dce4; // 잉크 위 강조 11.09:1
$sh-deep: #477690;
$duration: $dur-loop; // 넷이 차례로 라벨을 펼치는 한 바퀴
$easing: $ease-spring;
$color: $sh-sky; // 펼쳐진 라벨 면 색
@include stage($sh-ink);
.sr {
box-sizing: border-box;
width: clamp(288px, 88vw, 400px);
height: 216px;
overflow: hidden;
border-radius: $r-card;
background: $sh-ink2;
box-shadow: $shadow-raised;
}
.sr__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
// 화면보다 긴 문서 — 이 높이 차이가 sticky 가 걸릴 여유다.
// 여유를 padding-bottom 으로 주면 sticky 유효 범위(부모 콘텐츠 박스) 밖이라 안 먹는다
.sr__doc {
display: grid;
// 펼친 라벨이 본문 글자를 덮지 않게 첫 칸을 펼친 폭(136px)으로 미리 잡는다 —
// 접힌 폭(44px)으로 잡으면 펼치는 순간 라벨이 제목 위에 얹힌다 (첫 렌더 실측)
grid-template-columns: 136px 1fr;
gap: $sp-3;
min-height: 300px;
padding: $sp-3;
}
.sr__rail {
position: sticky;
top: 0;
align-self: start;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sr__link {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
// 펼친 폭(136px)만큼 상자를 미리 잡아 두고 오른쪽 100px 을 잘라 둔다 —
// 라벨 첫 글자는 여백 12px + 아이콘 20px + 간격 8px 인 40px 에서 시작하는데
// 남는 36px 이 그보다 안쪽이라 접힌 상태에 라벨 글자가 안 비친다
// (첫 렌더 실측: 92px 로 자르니 남는 44px 이 40px 을 넘어 첫 글자가 비쳤다)
// 펼침은 폭을 늘리는 게 아니라 가려 둔 면을 드러내는 것이라 레이아웃이 다시 계산되지 않는다
width: 136px;
height: 44px;
padding: 0 $sp-3;
border-radius: $r-pill;
background: $sh-ink;
color: $sh-mist;
text-decoration: none;
clip-path: inset(0 100px 0 0 round $r-pill);
transition: clip-path $dur-base $easing, background-color $dur-base $easing, color $dur-base $easing;
}
.sr__ic {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 20px;
}
.sr__ic svg { display: block; }
.sr__name {
white-space: nowrap;
font-weight: 600; font-size: 12px; line-height: 1;
font-family: inherit;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 라벨이 펼쳐진다
.sr__link:hover,
.sr__link:focus-visible {
clip-path: inset(0 0 0 0 round $r-pill);
background: $color;
color: $sh-ink; // 하늘색 면 위 잉크 글자 11.09:1
}
.sr__link:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: 2px;
}
.sr__body {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sr__title {
margin: 0;
color: $sh-paper;
font-weight: 700; font-size: 14px; line-height: 1.3;
font-family: inherit;
}
.sr__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: #3b3746;
}
.sr__row--full { width: 100%; }
.sr__row--wide { width: 84%; }
.sr__row--mid { width: 64%; }
.sr__row--short { width: 42%; }
// ── 자동 재생 ───────────────────────────────────────────────
// 넷이 0.16초씩 밀려 펼쳐진다. animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로,
// 지연 구간에 접힌 기본 상태가 번쩍이지 않게 fill-mode: backwards 를 같이 준다
.is-demo .sr__link {
animation-name: railUnfold;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sr__link:nth-child(1) { animation-delay: 0s; }
.is-demo .sr__link:nth-child(2) { animation-delay: .16s; }
.is-demo .sr__link:nth-child(3) { animation-delay: .32s; }
.is-demo .sr__link:nth-child(4) { animation-delay: .48s; }
// 펼침 10% · 접힘 16% 의 비대칭. 복귀는 90% 안에 끝난다 — 24프레임의 마지막 장이 95.8% 지점이다.
// clip-path 는 이징으로 보간되는 속성이라 steps 와 섞지 않는다
@keyframes railUnfold {
0% { clip-path: inset(0 100px 0 0 round $r-pill); background-color: $sh-ink; color: $sh-mist; }
10%, 46% { clip-path: inset(0 0 0 0 round $r-pill); background-color: $color; color: $sh-ink; }
62%, 100% { clip-path: inset(0 100px 0 0 round $r-pill); background-color: $sh-ink; color: $sh-mist; }
}
@media (max-width: 340px), (max-height: 220px) {
.sr { height: 156px; }
.sr__doc { grid-template-columns: 124px 1fr; gap: $sp-2; min-height: 216px; padding: $sp-2; }
.sr__rail { gap: $sp-1; }
.sr__link { width: 124px; height: 40px; padding: 0 $sp-2; clip-path: inset(0 92px 0 0 round $r-pill); }
.sr__title { font-size: 13px; }
.sr__row { height: 6px; }
.sr__ic svg { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .sr__link { animation: none !important; }
.sr__link { transition: none !important; }
// 움직임을 끊어도 호버·초점의 펼침은 그대로 남는다
.sr__link:hover, .sr__link:focus-visible { clip-path: inset(0 0 0 0 round $r-pill); }
}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);
}
.sr {
box-sizing: border-box;
width: clamp(288px, 88vw, 400px);
height: 216px;
overflow: hidden;
border-radius: 16px;
background: #26232e;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sr__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.sr__doc {
display: grid;
grid-template-columns: 136px 1fr;
gap: 12px;
min-height: 300px;
padding: 12px;
}
.sr__rail {
position: sticky;
top: 0;
align-self: start;
display: flex;
flex-direction: column;
gap: 8px;
}
.sr__link {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
width: 136px;
height: 44px;
padding: 0 12px;
border-radius: 999px;
background: #15131a;
color: #bdb7c6;
text-decoration: none;
clip-path: inset(0 100px 0 0 round 999px);
transition: clip-path 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__ic {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 20px;
}
.sr__ic svg {
display: block;
}
.sr__name {
white-space: nowrap;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.sr__link:hover,
.sr__link:focus-visible {
clip-path: inset(0 0 0 0 round 999px);
background: #c8dce4;
color: #15131a;
}
.sr__link:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sr__body {
display: flex;
flex-direction: column;
gap: 8px;
}
.sr__title {
margin: 0;
color: #ffffff;
font-weight: 700;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
}
.sr__row {
display: block;
height: 8px;
border-radius: 999px;
background: #3b3746;
}
.sr__row--full {
width: 100%;
}
.sr__row--wide {
width: 84%;
}
.sr__row--mid {
width: 64%;
}
.sr__row--short {
width: 42%;
}
.is-demo .sr__link {
animation-name: railUnfold;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sr__link:nth-child(1) {
animation-delay: 0s;
}
.is-demo .sr__link:nth-child(2) {
animation-delay: 0.16s;
}
.is-demo .sr__link:nth-child(3) {
animation-delay: 0.32s;
}
.is-demo .sr__link:nth-child(4) {
animation-delay: 0.48s;
}
@keyframes railUnfold {
0% {
clip-path: inset(0 100px 0 0 round 999px);
background-color: #15131a;
color: #bdb7c6;
}
10%, 46% {
clip-path: inset(0 0 0 0 round 999px);
background-color: #c8dce4;
color: #15131a;
}
62%, 100% {
clip-path: inset(0 100px 0 0 round 999px);
background-color: #15131a;
color: #bdb7c6;
}
}
@media (max-width: 340px), (max-height: 220px) {
.sr {
height: 156px;
}
.sr__doc {
grid-template-columns: 124px 1fr;
gap: 8px;
min-height: 216px;
padding: 8px;
}
.sr__rail {
gap: 4px;
}
.sr__link {
width: 124px;
height: 40px;
padding: 0 8px;
clip-path: inset(0 92px 0 0 round 999px);
}
.sr__title {
font-size: 13px;
}
.sr__row {
height: 6px;
}
.sr__ic svg {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sr__link {
animation: none !important;
}
.sr__link {
transition: none !important;
}
.sr__link:hover, .sr__link:focus-visible {
clip-path: inset(0 0 0 0 round 999px);
}
}(function () {
var body = document.body;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
document.querySelectorAll('.sr__link').forEach(function (a) {
a.addEventListener('pointerdown', hand);
a.addEventListener('focusin', hand);
a.addEventListener('click', function (e) { e.preventDefault(); hand(); });
});
document.querySelector('.sr__view').addEventListener('scroll', hand);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareSideRail.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type ClickEvt = { preventDefault(): void };
export type ShareSideRailProps = {
/** 바에 서는 공유 방법 이름 */
labels?: string[];
/** 넷이 차례로 라벨을 펼치는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 펼쳐진 라벨 면 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · 공유 */
const ICONS: ReactNode[] = [
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,200a32,32,0,1,1-32-32A32,32,0,0,1,208,200ZM176,88a32,32,0,1,0-32-32A32,32,0,0,0,176,88Z" opacity="0.2"/><path d="M176,160a39.89,39.89,0,0,0-28.62,12.09l-46.1-29.63a39.8,39.8,0,0,0,0-28.92l46.1-29.63a40,40,0,1,0-8.66-13.45l-46.1,29.63a40,40,0,1,0,0,55.82l46.1,29.63A40,40,0,1,0,176,160Zm0-128a24,24,0,1,1-24,24A24,24,0,0,1,176,32ZM64,152a24,24,0,1,1,24-24A24,24,0,0,1,64,152Zm112,72a24,24,0,1,1,24-24A24,24,0,0,1,176,224Z"/></svg>,
];
const ROWS = ["rowFull", "rowWide", "rowMid", "rowFull", "rowWide", "rowShort", "rowFull", "rowMid"];
export default function ShareSideRail({
labels = ["링크 복사", "메일 보내기", "메시지", "더 보기"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#7fd4f5",
autoPlay = true,
}: ShareSideRailProps) {
const [demo, setDemo] = useState(autoPlay);
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.wrap}>
<div className={styles.view} onScroll={stopDemo}>
<div className={styles.doc}>
<nav className={styles.rail} aria-label="공유 바">
{labels.map((name, i) => (
<a
key={name}
className={styles.link}
href="#"
onClick={(e: ClickEvt) => {
e.preventDefault();
stopDemo();
}}
onPointerDown={stopDemo}
onFocus={stopDemo}
>
<span className={styles.ic}>{ICONS[i]}</span>
<span className={styles.name}>{name}</span>
</a>
))}
</nav>
<article className={styles.body}>
<h2 className={styles.title}>긴 글은 옆에서 공유한다</h2>
{ROWS.map((cls, i) => (
<span key={i} className={styles.row + " " + styles[cls]} aria-hidden="true" />
))}
</article>
</div>
</div>
</div>
</div>
);
}02 · Floating side share rail — Measured area 15.565% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
After the 100px cut, 36px stay visible while the label only starts at 40px (12px padding, 20px icon, 8px gap), so no letter peeks while collapsed.
/* Reserve the full 136px width, then cut 100px off the right */
.sr__link {
width: 136px;
height: 44px;
clip-path: inset(0 100px 0 0 round $r-pill);
transition: clip-path $dur-base $easing;
}
.sr__link:hover,
.sr__link:focus-visible {
clip-path: inset(0 0 0 0 round $r-pill);
}
03Copy-link toast
What moves
Half of sharing is copying, so pressing the button actually writes the URL through navigator.clipboard.writeText and a toast rising from below reports the result. When the clipboard is missing it selects the input, and either way the toast says "copy it yourself" with a cross mark rather than a check — showing a check for a failure would be a lie.

<div class="stage">
<section class="ct" aria-label="Copy link">
<p class="ct__label">Share URL</p>
<div class="ct__field">
<input class="ct__url" type="text" value="https://example.com/posts/share-ui" readonly aria-label="Share URL">
<button class="ct__btn" type="button">
<span class="ct__ics" aria-hidden="true">
<span class="ct__ic ct__ic--copy"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg></span>
<span class="ct__ic ct__ic--done"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg></span>
</span>
<span class="ct__btnLabel">Copy</span>
</button>
</div>
<p class="ct__toast" role="status" aria-live="polite">
<span class="ct__toastIcs" aria-hidden="true">
<span class="ct__toastIc ct__toastIc--ok" 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="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg></span>
<span class="ct__toastIc ct__toastIc--bad" 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="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg></span>
</span>
<span class="ct__toastText">Link copied</span>
</p>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-deep: #477690; // 흰 면 위 강조 6.94:1
$sh-moss: #479074; // 완료 색 — 흰 면 위 6.65:1
$duration: $dur-loop; // 누름 → 복사 → 토스트 한 바퀴
$easing: $ease-pop; // 버튼 누름과 토스트 등장
$color: $sh-moss; // 완료 상태 색
@include stage($stage-yellow);
.ct {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: clamp(280px, 86vw, 384px);
padding: $sp-4;
padding-bottom: $sp-12; // 아래에서 올라오는 토스트 자리. 절대배치라 높이 계산에서 빠진다
border-radius: $r-card;
background: $sh-paper;
box-shadow: $shadow-raised;
}
.ct__label {
margin: 0;
color: $sh-slate; // 흰 면 위 보조 글자 7.15:1
font-weight: 600; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ct__field {
display: flex;
gap: $sp-2;
}
.ct__url {
flex: 1 1 auto;
box-sizing: border-box;
min-width: 0;
height: 40px;
padding: 0 $sp-3;
border: 1px solid #ddd6ca;
border-radius: $r-control;
background: $sh-cream;
color: $sh-ink;
font-weight: 500; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ct__url:focus-visible {
outline: 2px solid $sh-deep;
outline-offset: 1px;
}
.ct__btn {
flex: 0 0 auto;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 40px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
background: $sh-ink;
color: $sh-paper;
cursor: pointer;
transition: transform $dur-quick $easing, background-color $dur-quick $easing;
}
.ct__btn:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
// 아이콘 두 장을 같은 칸에 포갠다 — 상태가 바뀌어도 버튼 폭이 안 흔들린다
.ct__ics {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.ct__ic {
grid-area: 1 / 1;
display: grid;
place-items: center;
opacity: 0;
}
.ct__ic svg { display: block; }
.ct__ic--copy { opacity: 1; }
.ct__btnLabel {
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 완료 상태가 그대로 보인다
.ct.is-done .ct__btn { background: $color; }
.ct.is-done .ct__ic--copy { opacity: 0; }
.ct.is-done .ct__ic--done { opacity: 1; }
.ct__toast {
position: absolute;
left: $sp-4;
right: $sp-4;
bottom: $sp-3;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $sh-ink;
color: $sh-paper; // 잉크 위 흰 글자 18.42:1
font-weight: 600; font-size: 12px; line-height: 1.2;
font-family: inherit;
transform: translateY(120%);
transition: transform $dur-base $easing;
}
.ct.is-done .ct__toast { transform: translateY(0); }
// 확인 표시와 가위표를 같은 칸에 포갠다 — 결과가 바뀌어도 문장 자리가 안 밀린다
.ct__toastIc {
grid-area: 1 / 1;
display: grid;
place-items: center;
color: #b5dbcb; // 잉크 위 민트 확인 표시 11.51:1
}
.ct__toastIcs {
flex: 0 0 auto;
display: grid;
width: 16px;
height: 16px;
}
.ct__toastIc--bad { color: #ffb3a0; opacity: 0; } // 잉크 위 살구 가위표 10.71:1
.ct.is-bad .ct__toastIc--ok { opacity: 0; }
.ct.is-bad .ct__toastIc--bad { opacity: 1; }
.ct__toastIc svg { display: block; }
// 토스트가 스스로 사라지기까지 남은 시간 — 바닥에 붙은 가는 막대
.ct__toast::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: $r-pill;
background: #b5dbcb;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 좌변서 차오름
}
// ── 자동 재생 ───────────────────────────────────────────────
// 토스트는 루프 안에서 사라지지 않는다 — 대표 이미지는 앞 장과 가장 크게 달라진 장이라
// 퇴장을 재현하면 토스트 없는 빈 장이 뽑힌다. 되돌림은 루프 경계에서만 일어난다
.is-demo .ct__btn {
animation-name: ctPress;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 아이콘 교체는 steps(1, end) 컷 — 두 아이콘이 겹친 반투명 유령 장이 안 생긴다
.is-demo .ct__ic {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ct__ic--copy { animation-name: ctIcCopy; }
.is-demo .ct__ic--done { animation-name: ctIcDone; }
.is-demo .ct__toast {
animation-name: ctRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 막대가 천천히 닳으며 프레임을 채운다 — 컷만 있으면 움직인 프레임이 두 장뿐이다
.is-demo .ct__toast::after {
animation-name: ctDrain;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ctPress {
0%, 6% { transform: scale(1); background-color: $sh-ink; }
10% { transform: scale(.94); background-color: $sh-ink; }
16%, 100% { transform: scale(1); background-color: $color; }
}
@keyframes ctIcCopy { 0%, 13.9% { opacity: 1; } 14%, 100% { opacity: 0; } }
@keyframes ctIcDone { 0%, 13.9% { opacity: 0; } 14%, 100% { opacity: 1; } }
@keyframes ctRise {
0%, 14% { transform: translateY(120%); }
30%, 100% { transform: translateY(0); }
}
@keyframes ctDrain {
0%, 30% { transform: scaleX(1); }
96%, 100% { transform: scaleX(.04); }
}
@media (max-width: 340px), (max-height: 220px) {
.ct { gap: $sp-1; padding: $sp-3; padding-bottom: $sp-10; }
.ct__url { height: 36px; padding: 0 $sp-2; font-size: 11px; }
.ct__btn { height: 36px; padding: 0 $sp-2; }
.ct__toast { left: $sp-2; right: $sp-2; bottom: $sp-2; padding: $sp-2; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ct__btn, .is-demo .ct__ic, .is-demo .ct__toast, .is-demo .ct__toast::after {
animation: none !important;
}
.ct__btn, .ct__toast { transition: none !important; }
// 움직임을 끊어도 복사 결과는 토스트로 그대로 읽힌다
.ct.is-done .ct__toast { transform: translateY(0); }
.ct__toast::after { transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ct {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: clamp(280px, 86vw, 384px);
padding: 16px;
padding-bottom: 48px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ct__label {
margin: 0;
color: #5b5566;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ct__field {
display: flex;
gap: 8px;
}
.ct__url {
flex: 1 1 auto;
box-sizing: border-box;
min-width: 0;
height: 40px;
padding: 0 12px;
border: 1px solid #ddd6ca;
border-radius: 12px;
background: #fdf8f0;
color: #15131a;
font-weight: 500;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ct__url:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ct__btn {
flex: 0 0 auto;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: #15131a;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ct__btn:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.ct__ics {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.ct__ic {
grid-area: 1/1;
display: grid;
place-items: center;
opacity: 0;
}
.ct__ic svg {
display: block;
}
.ct__ic--copy {
opacity: 1;
}
.ct__btnLabel {
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ct.is-done .ct__btn {
background: #479074;
}
.ct.is-done .ct__ic--copy {
opacity: 0;
}
.ct.is-done .ct__ic--done {
opacity: 1;
}
.ct__toast {
position: absolute;
left: 16px;
right: 16px;
bottom: 12px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 12px;
border-radius: 12px;
background: #15131a;
color: #ffffff;
font-weight: 600;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
transform: translateY(120%);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ct.is-done .ct__toast {
transform: translateY(0);
}
.ct__toastIc {
grid-area: 1/1;
display: grid;
place-items: center;
color: #b5dbcb;
}
.ct__toastIcs {
flex: 0 0 auto;
display: grid;
width: 16px;
height: 16px;
}
.ct__toastIc--bad {
color: #ffb3a0;
opacity: 0;
}
.ct.is-bad .ct__toastIc--ok {
opacity: 0;
}
.ct.is-bad .ct__toastIc--bad {
opacity: 1;
}
.ct__toastIc svg {
display: block;
}
.ct__toast::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: 999px;
background: #b5dbcb;
transform: scaleX(0);
transform-origin: left center;
}
.is-demo .ct__btn {
animation-name: ctPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .ct__ic {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ct__ic--copy {
animation-name: ctIcCopy;
}
.is-demo .ct__ic--done {
animation-name: ctIcDone;
}
.is-demo .ct__toast {
animation-name: ctRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .ct__toast::after {
animation-name: ctDrain;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ctPress {
0%, 6% {
transform: scale(1);
background-color: #15131a;
}
10% {
transform: scale(0.94);
background-color: #15131a;
}
16%, 100% {
transform: scale(1);
background-color: #479074;
}
}
@keyframes ctIcCopy {
0%, 13.9% {
opacity: 1;
}
14%, 100% {
opacity: 0;
}
}
@keyframes ctIcDone {
0%, 13.9% {
opacity: 0;
}
14%, 100% {
opacity: 1;
}
}
@keyframes ctRise {
0%, 14% {
transform: translateY(120%);
}
30%, 100% {
transform: translateY(0);
}
}
@keyframes ctDrain {
0%, 30% {
transform: scaleX(1);
}
96%, 100% {
transform: scaleX(0.04);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ct {
gap: 4px;
padding: 12px;
padding-bottom: 40px;
}
.ct__url {
height: 36px;
padding: 0 8px;
font-size: 11px;
}
.ct__btn {
height: 36px;
padding: 0 8px;
}
.ct__toast {
left: 8px;
right: 8px;
bottom: 8px;
padding: 8px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ct__btn, .is-demo .ct__ic, .is-demo .ct__toast, .is-demo .ct__toast::after {
animation: none !important;
}
.ct__btn, .ct__toast {
transition: none !important;
}
.ct.is-done .ct__toast {
transform: translateY(0);
}
.ct__toast::after {
transform: scaleX(1);
}
}(function () {
var body = document.body;
var btn = document.querySelector('.ct__btn');
var input = document.querySelector('.ct__url');
var root = document.querySelector('.ct');
var text = document.querySelector('.ct__toastText');
var timer = 0;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
function show(msg, bad) {
text.textContent = msg;
root.classList.toggle('is-bad', !!bad); // 막힌 경우엔 확인 표시 대신 가위표
root.classList.add('is-done');
clearTimeout(timer);
timer = setTimeout(function () { root.classList.remove('is-done'); }, 1800);
}
btn.addEventListener('click', function () {
hand();
var url = input.value;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
function () { show('Link copied', false); },
function () { show('Copying was blocked. Select it yourself', true); }
);
return;
}
input.select(); // 클립보드 API 가 없는 환경의 대비책
show('The URL is selected. Copy it yourself', true);
});
btn.addEventListener('pointerdown', hand);
btn.addEventListener('focusin', hand);
input.addEventListener('focusin', hand);
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShareCopyToast.module.scss";
/* ambient 에 React 의 이벤트 제네릭·타이머 타입이 없어 필요한 것만 로컬로 선언한다 */
type Box<T> = { current: T };
type InputEl = { value: string; select(): void };
export type ShareCopyToastProps = {
/** 복사될 주소 */
url?: string;
/** 누름 → 복사 → 토스트 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 완료 상태 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShareCopyToast({
url = "https://example.com/posts/share-ui",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17694a",
autoPlay = true,
}: ShareCopyToastProps) {
const [demo, setDemo] = useState(autoPlay);
const [done, setDone] = useState(false);
const [bad, setBad] = useState(false);
const [message, setMessage] = useState("주소를 복사했습니다");
const inputRef = useRef<InputEl>(null) as Box<InputEl | null>;
const timerRef = useRef<number>(0) as Box<number>;
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function showResult(text: string, failed: boolean) {
setMessage(text);
setBad(failed);
setDone(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setDone(false), 1800) as unknown as number;
}
function applyCopy() {
stopDemo();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
() => showResult("주소를 복사했습니다", false),
() => showResult("복사가 막혔습니다. 직접 선택해 주세요", true)
);
return;
}
/* 클립보드 API 가 없는 환경의 대비책 */
if (inputRef.current) inputRef.current.select();
showResult("주소를 선택했습니다. 직접 복사해 주세요", true);
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const rootClass =
styles.copy + (done ? " " + styles.isDone : "") + (bad ? " " + styles.isBad : "");
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section className={rootClass} aria-label="링크 복사">
<p className={styles.label}>공유 주소</p>
<div className={styles.field}>
<input
className={styles.url}
type="text"
defaultValue={url}
readOnly
aria-label="공유 주소"
ref={inputRef as unknown as never}
onFocus={stopDemo}
/>
<button className={styles.btn} type="button" onClick={applyCopy} onPointerDown={stopDemo}>
<span className={styles.ics} aria-hidden="true">
<span className={styles.ic + " " + styles.icCopy}><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg></span>
<span className={styles.ic + " " + styles.icDone}><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg></span>
</span>
<span className={styles.btnLabel}>복사</span>
</button>
</div>
<p className={styles.toast} role="status" aria-live="polite">
<span className={styles.toastIcs} aria-hidden="true">
<span className={styles.toastIc + " " + styles.toastIcOk}><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z" opacity="0.2"/><path d="M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"/></svg></span>
<span className={styles.toastIc + " " + styles.toastIcBad}><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg></span>
</span>
<span className={styles.toastText}>{message}</span>
</p>
</section>
</div>
);
}03 · Copy-link toast — Measured area 19.812% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
One role="status" region reads the outcome.
function show(msg, failed) {
text.textContent = msg;
root.classList.toggle('is-bad', !!failed); // blocked: cross mark, not a check
root.classList.add('is-done');
}
navigator.clipboard.writeText(url).then(
function () { show('Link copied', false); },
function () { show('Copying was blocked. Select it yourself', true); }
);
04Share-count count-up
What you see
Badges showing how often a page traveled are proof, yet rolling the number from zero up to the goal needs no JavaScript counter — a custom property registered with @property and a CSS counter do it in place. Using it without registration leaves it untyped, un-interpolated, and good for one mid-flight blip; that is the crux of this pattern.

<div class="stage">
<section class="cb" aria-label="Share counts">
<p class="cb__title">Most shared this week</p>
<div class="cb__btns">
<button class="cb__btn" type="button" aria-label="Copy link, shared 128 times">
<span class="cb__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="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></span>
<span class="cb__name">Link</span>
<span class="cb__badge cb__badge--a" aria-hidden="true"></span>
</button>
<button class="cb__btn" type="button" aria-label="Send by mail, shared 64 times">
<span class="cb__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="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
<span class="cb__name">Mail</span>
<span class="cb__badge cb__badge--b" aria-hidden="true"></span>
</button>
<button class="cb__btn" type="button" aria-label="Send in a message, shared 39 times">
<span class="cb__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="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="cb__name">Message</span>
<span class="cb__badge cb__badge--c" aria-hidden="true"></span>
</button>
</div>
<p class="cb__total">
<span class="cb__totalIc" 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="M232,56v64L168,56Z" opacity="0.2"/><path d="M232,48H168a8,8,0,0,0-5.66,13.66L188.69,88,136,140.69l-34.34-34.35a8,8,0,0,0-11.32,0l-72,72a8,8,0,0,0,11.32,11.32L96,123.31l34.34,34.35a8,8,0,0,0,11.32,0L200,99.31l26.34,26.35A8,8,0,0,0,240,120V56A8,8,0,0,0,232,48Zm-8,52.69L187.31,64H224Z"/></svg></span>
<span>231 more shares than last week</span>
</p>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-deep: #477690;
$duration: $dur-loop; // 세 배지가 0 에서 목표까지 오르는 한 바퀴
$easing: $ease-pop; // 도착할 때 한 번 튄다
$color: $sh-ink; // 배지 면 색
// 숫자를 굴리는 건 자바스크립트 카운터가 아니라 등록한 사용자 지정 속성이다.
// @property 로 타입을 주지 않으면 보간이 안 되고 중간에 한 번 툭 바뀔 뿐이다 (262·239 실측)
@property --shareCount {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
@include stage($stage-orange);
.cb {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: clamp(280px, 86vw, 384px);
padding: $sp-4;
border-radius: $r-card;
background: $sh-paper;
box-shadow: $shadow-raised;
}
.cb__title {
margin: 0;
color: $sh-ink;
font-weight: 700; font-size: 15px; line-height: 1.2;
font-family: inherit;
}
// 칸을 격자로 나눈다 — 배지가 어디에 붙든 세 칸 폭이 정확히 같다
.cb__btns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $sp-2;
}
.cb__btn {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-2;
border: 1px solid #ddd6ca;
border-radius: $r-control;
background: $sh-cream;
color: $sh-ink;
cursor: pointer;
transition: border-color $dur-quick $easing;
}
.cb__btn:hover, .cb__btn:focus-visible { border-color: $sh-deep; }
.cb__btn:focus-visible {
outline: 2px solid $sh-deep;
outline-offset: 2px;
}
.cb__ic { display: grid; place-items: center; }
.cb__ic svg { display: block; }
.cb__name {
font-weight: 600; font-size: 11px; line-height: 1;
font-family: inherit;
}
// 숫자는 counter-reset 이 사용자 지정 속성을 받고 content 가 그 카운터를 읽는다.
// 값은 그 요소 자신의 것이라야 한다 — inherits: false 로 등록했으니 부모에 걸면 안 먹는다
.cb__badge {
position: absolute;
top: -8px;
right: -6px;
box-sizing: border-box;
min-width: 28px;
padding: $sp-1;
border-radius: $r-pill;
background: $color;
color: $sh-paper; // 잉크 위 흰 숫자 18.42:1
text-align: center;
font-weight: 700; font-size: 11px; line-height: 1.2;
font-family: inherit;
counter-reset: shareCount var(--shareCount);
}
.cb__badge::after { content: counter(shareCount); }
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 최종 숫자가 그대로 보인다
.cb__badge--a { --shareCount: 128; }
.cb__badge--b { --shareCount: 64; }
.cb__badge--c { --shareCount: 39; }
.cb__total {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
color: $sh-slate; // 흰 면 위 보조 글자 7.15:1
font-weight: 600; font-size: 12px; line-height: 1.2;
font-family: inherit;
}
.cb__totalIc { display: grid; place-items: center; color: $sh-deep; }
.cb__totalIc svg { display: block; }
// ── 자동 재생 ───────────────────────────────────────────────
// 숫자가 오르는 동안 배지가 한 번 튄다. animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로,
// 지연 구간에 최종 숫자가 한 프레임 번쩍이지 않게 fill-mode: backwards 를 같이 준다
.is-demo .cb__badge {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .cb__badge--a { animation-name: countA; animation-delay: 0s; }
.is-demo .cb__badge--b { animation-name: countB; animation-delay: .12s; }
.is-demo .cb__badge--c { animation-name: countC; animation-delay: .24s; }
// 숫자는 50% 에 목표에 닿고 그 뒤에 배지가 한 번 튄다 — 칸을 채우는 것과 값이 멎는 것이
// 같은 시각이면 가장 크게 달라진 장이 "아직 덜 센 숫자" 가 된다 (첫 렌더 실측: 126·56·26).
// 숫자를 먼저 세우고 튐을 뒤에 두면 대표 이미지가 완성된 값으로 뽑힌다
@keyframes countA {
0% { --shareCount: 0; transform: scale(.86); }
50% { --shareCount: 128; transform: scale(1); }
58% { --shareCount: 128; transform: scale(1.2); }
70%, 100% { --shareCount: 128; transform: scale(1); }
}
@keyframes countB {
0% { --shareCount: 0; transform: scale(.86); }
50% { --shareCount: 64; transform: scale(1); }
58% { --shareCount: 64; transform: scale(1.2); }
70%, 100% { --shareCount: 64; transform: scale(1); }
}
@keyframes countC {
0% { --shareCount: 0; transform: scale(.86); }
50% { --shareCount: 39; transform: scale(1); }
58% { --shareCount: 39; transform: scale(1.2); }
70%, 100% { --shareCount: 39; transform: scale(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.cb { gap: $sp-2; padding: $sp-3; }
.cb__title { font-size: 13px; }
.cb__btn { padding: $sp-2 $sp-1; }
.cb__ic svg { width: 18px; height: 18px; }
.cb__name { font-size: 10px; }
.cb__badge { min-width: 24px; font-size: 10px; }
.cb__total { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .cb__badge { animation: none !important; }
.cb__btn { transition: none !important; }
// 움직임을 끊어도 숫자는 선언된 최종값으로 그대로 보인다
.cb__badge { transform: scale(1); }
}@property --shareCount {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: clamp(280px, 86vw, 384px);
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cb__title {
margin: 0;
color: #15131a;
font-weight: 700;
font-size: 15px;
line-height: 1.2;
font-family: inherit;
}
.cb__btns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cb__btn {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid #ddd6ca;
border-radius: 12px;
background: #fdf8f0;
color: #15131a;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__btn:hover, .cb__btn:focus-visible {
border-color: #477690;
}
.cb__btn:focus-visible {
outline: 2px solid #477690;
outline-offset: 2px;
}
.cb__ic {
display: grid;
place-items: center;
}
.cb__ic svg {
display: block;
}
.cb__name {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
}
.cb__badge {
position: absolute;
top: -8px;
right: -6px;
box-sizing: border-box;
min-width: 28px;
padding: 4px;
border-radius: 999px;
background: #15131a;
color: #ffffff;
text-align: center;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
font-family: inherit;
counter-reset: shareCount var(--shareCount);
}
.cb__badge::after {
content: counter(shareCount);
}
.cb__badge--a {
--shareCount: 128;
}
.cb__badge--b {
--shareCount: 64;
}
.cb__badge--c {
--shareCount: 39;
}
.cb__total {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: #5b5566;
font-weight: 600;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
}
.cb__totalIc {
display: grid;
place-items: center;
color: #477690;
}
.cb__totalIc svg {
display: block;
}
.is-demo .cb__badge {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .cb__badge--a {
animation-name: countA;
animation-delay: 0s;
}
.is-demo .cb__badge--b {
animation-name: countB;
animation-delay: 0.12s;
}
.is-demo .cb__badge--c {
animation-name: countC;
animation-delay: 0.24s;
}
@keyframes countA {
0% {
--shareCount: 0;
transform: scale(0.86);
}
50% {
--shareCount: 128;
transform: scale(1);
}
58% {
--shareCount: 128;
transform: scale(1.2);
}
70%, 100% {
--shareCount: 128;
transform: scale(1);
}
}
@keyframes countB {
0% {
--shareCount: 0;
transform: scale(0.86);
}
50% {
--shareCount: 64;
transform: scale(1);
}
58% {
--shareCount: 64;
transform: scale(1.2);
}
70%, 100% {
--shareCount: 64;
transform: scale(1);
}
}
@keyframes countC {
0% {
--shareCount: 0;
transform: scale(0.86);
}
50% {
--shareCount: 39;
transform: scale(1);
}
58% {
--shareCount: 39;
transform: scale(1.2);
}
70%, 100% {
--shareCount: 39;
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.cb {
gap: 8px;
padding: 12px;
}
.cb__title {
font-size: 13px;
}
.cb__btn {
padding: 8px 4px;
}
.cb__ic svg {
width: 18px;
height: 18px;
}
.cb__name {
font-size: 10px;
}
.cb__badge {
min-width: 24px;
font-size: 10px;
}
.cb__total {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .cb__badge {
animation: none !important;
}
.cb__btn {
transition: none !important;
}
.cb__badge {
transform: scale(1);
}
}(function () {
var body = document.body;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
document.querySelectorAll('.cb__btn').forEach(function (b) {
b.addEventListener('pointerdown', hand);
b.addEventListener('focusin', hand);
b.addEventListener('click', hand);
});
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareCountBadge.module.scss";
export type ShareCountBadgeProps = {
/** 버튼 이름과 공유 수 */
items?: { name: string; label: string; count: number }[];
/** 세 배지가 0 에서 목표까지 오르는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 배지 면 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BadgeVars = CSSProperties & { "--shareCount"?: number };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 */
const ICONS: ReactNode[] = [
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
];
const BADGE = ["badgeA", "badgeB", "badgeC"];
export default function ShareCountBadge({
items = [
{ name: "링크", label: "링크 복사", count: 128 },
{ name: "메일", label: "메일로 보내기", count: 64 },
{ name: "메시지", label: "메시지로 보내기", count: 39 },
],
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#15131a",
autoPlay = true,
}: ShareCountBadgeProps) {
const [demo, setDemo] = useState(autoPlay);
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section className={styles.count} aria-label="공유 수">
<p className={styles.title}>이번 주 많이 나간 글</p>
<div className={styles.btns}>
{items.map((item, i) => (
<button
key={item.name}
className={styles.btn}
type="button"
aria-label={item.label + ", 공유 " + item.count + "회"}
onClick={stopDemo}
onPointerDown={stopDemo}
onFocus={stopDemo}
>
<span className={styles.ic}>{ICONS[i]}</span>
<span className={styles.name}>{item.name}</span>
{/* 숫자는 등록한 사용자 지정 속성을 그 요소 자신이 받아 counter 로 읽는다 */}
<span
className={styles.badge + " " + styles[BADGE[i]]}
aria-hidden="true"
style={{ "--shareCount": item.count } as BadgeVars}
/>
</button>
))}
</div>
<p className={styles.total}>
<span className={styles.totalIc} 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="M232,56v64L168,56Z" opacity="0.2"/><path d="M232,48H168a8,8,0,0,0-5.66,13.66L188.69,88,136,140.69l-34.34-34.35a8,8,0,0,0-11.32,0l-72,72a8,8,0,0,0,11.32,11.32L96,123.31l34.34,34.35a8,8,0,0,0,11.32,0L200,99.31l26.34,26.35A8,8,0,0,0,240,120V56A8,8,0,0,0,232,48Zm-8,52.69L187.31,64H224Z"/></svg></span>
<span>지난주보다 231회 더 나갔습니다</span>
</p>
</section>
</div>
);
}04 · Share-count count-up — Measured area 1.206% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Because it is registered with inherits: false, the value must sit on the badge itself.
@property --shareCount {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.cb__badge {
counter-reset: shareCount var(--shareCount);
}
.cb__badge::after { content: counter(shareCount); }
.cb__badge--a { --shareCount: 128; } /* final value outside .is-demo */
05Expanding icon share button
What happens on screen
On a one-page portfolio with a narrow header, you keep a single share button, and pressing it fans four platform buttons out sideways with a stagger. Collapsing is not shrinking the width to zero but cutting the buttons fully off with clip-path, so their places stay reserved and neighboring text never gets pushed.
<div class="stage">
<section class="ex" aria-label="Expand share options">
<div class="ex__bar">
<button class="ex__toggle" type="button" aria-expanded="false" aria-controls="ex-list">
<span class="ex__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="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span class="ex__toggleLabel">Share</span>
</button>
<div class="ex__list" id="ex-list">
<a class="ex__item" href="#" aria-label="Copy link"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></a>
<a class="ex__item" href="#" aria-label="Send by mail"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></a>
<a class="ex__item" href="#" aria-label="Send in a message"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></a>
<a class="ex__item" href="#" aria-label="Open in a new window"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,80V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H176A8,8,0,0,1,184,80Z" opacity="0.2"/><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg></a>
</div>
</div>
<p class="ex__hint">In tight spots, keep just one button</p>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-mist: #bdb7c6; // 잉크 위 보조 글자 9.43:1
$sh-sky: #c8dce4; // 잉크 위 강조 11.09:1
$duration: $dur-loop; // 펼침 → 머무름 → 접힘 한 바퀴
$easing: $ease-spring;
$color: $sh-sky; // 펼침 버튼 면 색
@include stage($sh-ink);
.ex {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
width: clamp(288px, 88vw, 400px);
padding: $sp-6 $sp-4;
border-radius: $r-card;
background: $sh-ink2;
box-shadow: $shadow-raised;
}
.ex__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.ex__toggle {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 44px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-pill;
background: $color;
color: $sh-ink; // 하늘색 면 위 잉크 글자 11.09:1
cursor: pointer;
transition: transform $dur-quick $easing;
}
.ex__toggle:hover { transform: translateY(-2px); }
.ex__toggle:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: 2px;
}
.ex__ic { display: grid; place-items: center; }
.ex__ic svg { display: block; }
.ex__toggleLabel {
font-weight: 700; font-size: 13px; line-height: 1;
font-family: inherit;
}
.ex__list {
display: flex;
gap: $sp-2;
}
// 접힘은 폭을 0 으로 줄이는 게 아니라 왼쪽 끝에서 잘라 두는 것이다 —
// 자리를 미리 잡아 두니 펼쳐도 옆 글자가 밀리지 않는다
.ex__item {
box-sizing: border-box;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: $sh-ink;
color: $sh-mist;
text-decoration: none;
transform: translateX(-16px);
clip-path: inset(0 100% 0 0 round 50%);
transition: clip-path $dur-base $easing, transform $dur-base $easing, color $dur-base $easing;
}
.ex__item svg { display: block; }
.ex__item:hover, .ex__item:focus-visible { color: $color; }
.ex__item:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: 2px;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 펼침 상태가 그대로 보인다
.ex.is-open .ex__item {
transform: translateX(0);
clip-path: inset(0 0 0 0 round 50%);
}
.ex.is-open .ex__item:nth-child(1) { transition-delay: 0ms; }
.ex.is-open .ex__item:nth-child(2) { transition-delay: 60ms; }
.ex.is-open .ex__item:nth-child(3) { transition-delay: 120ms; }
.ex.is-open .ex__item:nth-child(4) { transition-delay: 180ms; }
.ex__hint {
margin: 0;
color: $sh-mist;
font-weight: 500; font-size: 12px; line-height: 1;
font-family: inherit;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 넷이 시차로 펼쳐졌다가 접힌다. animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로,
// 지연 구간에 접힌 기본 상태가 번쩍이지 않게 fill-mode: backwards 를 같이 준다
.is-demo .ex__item {
animation-name: exFan;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ex__item:nth-child(1) { animation-delay: 0s; }
.is-demo .ex__item:nth-child(2) { animation-delay: .1s; }
.is-demo .ex__item:nth-child(3) { animation-delay: .2s; }
.is-demo .ex__item:nth-child(4) { animation-delay: .3s; }
.is-demo .ex__toggle {
animation-name: exPress;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 펼침 12% · 접힘 18%. 접힘은 84% 에 끝난다 — 24프레임의 마지막 장이 95.8% 지점이라
// 복귀를 그 뒤에 두면 어느 프레임에도 안 잡힌다
@keyframes exFan {
0% { clip-path: inset(0 100% 0 0 round 50%); transform: translateX(-16px); color: $sh-mist; }
12%, 62% { clip-path: inset(0 0 0 0 round 50%); transform: translateX(0); color: $color; }
84%, 100% { clip-path: inset(0 100% 0 0 round 50%); transform: translateX(-16px); color: $sh-mist; }
}
@keyframes exPress {
0%, 2% { transform: scale(1); }
6% { transform: scale(.92); }
12%, 100% { transform: scale(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.ex { gap: $sp-2; padding: $sp-3; }
.ex__toggle { height: 40px; padding: 0 $sp-3; }
.ex__toggleLabel { font-size: 12px; }
.ex__item { width: 40px; height: 40px; }
.ex__ic svg, .ex__item svg { width: 18px; height: 18px; }
.ex__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ex__item, .is-demo .ex__toggle { animation: none !important; }
.ex__item, .ex__toggle { transition: none !important; }
// 움직임을 끊어도 펼친 상태는 그대로 남는다
.ex.is-open .ex__item { clip-path: inset(0 0 0 0 round 50%); transform: translateX(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);
}
.ex {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: clamp(288px, 88vw, 400px);
padding: 24px 16px;
border-radius: 16px;
background: #26232e;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ex__bar {
display: flex;
align-items: center;
gap: 8px;
}
.ex__toggle {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: #c8dce4;
color: #15131a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ex__toggle:hover {
transform: translateY(-2px);
}
.ex__toggle:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.ex__ic {
display: grid;
place-items: center;
}
.ex__ic svg {
display: block;
}
.ex__toggleLabel {
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.ex__list {
display: flex;
gap: 8px;
}
.ex__item {
box-sizing: border-box;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #15131a;
color: #bdb7c6;
text-decoration: none;
transform: translateX(-16px);
clip-path: inset(0 100% 0 0 round 50%);
transition: clip-path 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ex__item svg {
display: block;
}
.ex__item:hover, .ex__item:focus-visible {
color: #c8dce4;
}
.ex__item:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.ex.is-open .ex__item {
transform: translateX(0);
clip-path: inset(0 0 0 0 round 50%);
}
.ex.is-open .ex__item:nth-child(1) {
transition-delay: 0ms;
}
.ex.is-open .ex__item:nth-child(2) {
transition-delay: 60ms;
}
.ex.is-open .ex__item:nth-child(3) {
transition-delay: 120ms;
}
.ex.is-open .ex__item:nth-child(4) {
transition-delay: 180ms;
}
.ex__hint {
margin: 0;
color: #bdb7c6;
font-weight: 500;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.is-demo .ex__item {
animation-name: exFan;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ex__item:nth-child(1) {
animation-delay: 0s;
}
.is-demo .ex__item:nth-child(2) {
animation-delay: 0.1s;
}
.is-demo .ex__item:nth-child(3) {
animation-delay: 0.2s;
}
.is-demo .ex__item:nth-child(4) {
animation-delay: 0.3s;
}
.is-demo .ex__toggle {
animation-name: exPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes exFan {
0% {
clip-path: inset(0 100% 0 0 round 50%);
transform: translateX(-16px);
color: #bdb7c6;
}
12%, 62% {
clip-path: inset(0 0 0 0 round 50%);
transform: translateX(0);
color: #c8dce4;
}
84%, 100% {
clip-path: inset(0 100% 0 0 round 50%);
transform: translateX(-16px);
color: #bdb7c6;
}
}
@keyframes exPress {
0%, 2% {
transform: scale(1);
}
6% {
transform: scale(0.92);
}
12%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ex {
gap: 8px;
padding: 12px;
}
.ex__toggle {
height: 40px;
padding: 0 12px;
}
.ex__toggleLabel {
font-size: 12px;
}
.ex__item {
width: 40px;
height: 40px;
}
.ex__ic svg, .ex__item svg {
width: 18px;
height: 18px;
}
.ex__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ex__item, .is-demo .ex__toggle {
animation: none !important;
}
.ex__item, .ex__toggle {
transition: none !important;
}
.ex.is-open .ex__item {
clip-path: inset(0 0 0 0 round 50%);
transform: translateX(0);
}
}(function () {
var body = document.body;
var toggle = document.querySelector('.ex__toggle');
var root = document.querySelector('.ex');
var items = Array.prototype.slice.call(document.querySelectorAll('.ex__item'));
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
function setOpen(open) {
root.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
items.forEach(function (a) { a.tabIndex = open ? 0 : -1; }); // 접힌 목록은 Tab 순서에서 뺀다
}
toggle.addEventListener('click', function () {
hand();
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
toggle.addEventListener('pointerdown', hand);
toggle.addEventListener('focusin', hand);
root.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hand();
setOpen(false);
toggle.focus({ preventScroll: true });
});
items.forEach(function (a) {
a.addEventListener('click', function (e) { e.preventDefault(); hand(); });
});
setOpen(false);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareExpandButton.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type ClickEvt = { preventDefault(): void };
type KeyEvt = { key: string };
type FocusBox = { focus(opts: { preventScroll: boolean }): void };
export type ShareExpandButtonProps = {
/** 펼쳐지는 버튼 넷의 접근성 이름 */
labels?: string[];
/** 펼침 → 머무름 → 접힘 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 펼침 버튼 면 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · 새 창 */
const ICONS: ReactNode[] = [
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,80V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H176A8,8,0,0,1,184,80Z" opacity="0.2"/><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>,
];
export default function ShareExpandButton({
labels = ["링크 복사", "메일로 보내기", "메시지로 보내기", "새 창으로 열기"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#7fd4f5",
autoPlay = true,
}: ShareExpandButtonProps) {
const [demo, setDemo] = useState(autoPlay);
const [open, setOpen] = useState(false);
const toggleRef = useRef<FocusBox>(null) as { current: FocusBox | null };
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyToggle() {
stopDemo();
setOpen((prev) => !prev);
}
function applyKey(e: KeyEvt) {
if (e.key !== "Escape") return;
stopDemo();
setOpen(false);
if (toggleRef.current) toggleRef.current.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section
className={styles.expand + (open ? " " + styles.isOpen : "")}
aria-label="공유 펼치기"
onKeyDown={applyKey}
>
<div className={styles.bar}>
<button
className={styles.toggle}
type="button"
aria-expanded={open}
onClick={applyToggle}
onPointerDown={stopDemo}
onFocus={stopDemo}
ref={toggleRef as unknown as never}
>
<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="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span className={styles.toggleLabel}>공유</span>
</button>
<div className={styles.list}>
{labels.map((name, i) => (
<a
key={name}
className={styles.item}
href="#"
aria-label={name}
tabIndex={open ? 0 : -1}
onClick={(e: ClickEvt) => {
e.preventDefault();
stopDemo();
}}
>
{ICONS[i]}
</a>
))}
</div>
</div>
<p className={styles.hint}>좁은 자리에서는 버튼 하나만 둡니다</p>
</section>
</div>
);
}05 · Expanding icon share button — Measured area 5.952% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
While collapsed, the buttons drop out of the Tab order with tabIndex -1, and Escape folds them back and returns focus to the share button.
.ex__item {
transform: translateX(-16px);
clip-path: inset(0 100% 0 0 round 50%);
transition: clip-path $dur-base $easing, transform $dur-base $easing;
}
.ex.is-open .ex__item {
transform: translateX(0);
clip-path: inset(0 0 0 0 round 50%);
}
.ex.is-open .ex__item:nth-child(2) { transition-delay: 60ms; }
06Mobile share sheet
What moves
On phones, the sheet rising from the bottom is the familiar shape: pressing share raises a role="dialog" sheet, dims the backdrop, and the four cells surface a beat after the sheet. The backbone is the focus trap.

<div class="stage">
<div class="ms">
<div class="ms__screen">
<p class="ms__title">Today's notes</p>
<span class="ms__row ms__row--full" aria-hidden="true"></span>
<span class="ms__row ms__row--wide" aria-hidden="true"></span>
<span class="ms__row ms__row--mid" aria-hidden="true"></span>
<button class="ms__open" type="button">
<span class="ms__ic"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span>Share</span>
</button>
</div>
<div class="ms__scrim" aria-hidden="true"></div>
<div class="ms__sheet" role="dialog" aria-modal="true" aria-label="Choose how to share">
<span class="ms__grip" aria-hidden="true"></span>
<div class="ms__grid">
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></span>
<span class="ms__pickName">Copy link</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
<span class="ms__pickName">Mail</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="ms__pickName">Message</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><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="ms__pickName">QR code</span>
</button>
</div>
<button class="ms__close" type="button">Close</button>
</div>
</div>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-deep: #477690;
$duration: $dur-loop; // 열림 → 머무름 한 바퀴
$easing: $ease-spring;
$color: $sh-deep; // 고른 칸 강조 색
@include stage($sh-paper);
.ms {
position: relative;
box-sizing: border-box;
width: clamp(232px, 72vw, 272px);
height: 216px;
overflow: hidden;
border-radius: $r-card;
background: $sh-cream;
box-shadow: $shadow-raised;
}
.ms__screen {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-4;
}
.ms__title {
margin: 0;
color: $sh-ink;
font-weight: 700; font-size: 14px; line-height: 1.2;
font-family: inherit;
}
.ms__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: #e3ddd2;
}
.ms__row--full { width: 100%; }
.ms__row--wide { width: 86%; }
.ms__row--mid { width: 60%; }
.ms__open {
align-self: flex-start;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $sh-ink;
color: $sh-paper;
cursor: pointer;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ms__open:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
.ms__ic { display: grid; place-items: center; }
.ms__ic svg { display: block; }
// 뒷면 — 시트가 열린 동안만 어두워진다
.ms__scrim {
position: absolute;
inset: 0;
background: rgba(21, 19, 26, .48);
opacity: 0;
pointer-events: none;
transition: opacity $dur-base $easing;
}
.ms__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2 $sp-3 $sp-3;
border-radius: $r-card $r-card 0 0;
background: $sh-paper;
transform: translateY(100%);
transition: transform $dur-base $easing;
}
.ms__grip {
align-self: center;
width: 36px;
height: 4px;
border-radius: $r-pill;
background: #ddd6ca;
}
.ms__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: $sp-1;
}
.ms__pick {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-2 0;
border: 0;
border-radius: $r-control;
background: $sh-cream;
color: $sh-ink;
cursor: pointer;
transform: translateY(10px);
transition: transform $dur-base $easing, background-color $dur-quick $easing;
}
.ms__pick:hover, .ms__pick:focus-visible { background: #f0e9dd; }
.ms__pick:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
.ms__pickIc { display: grid; place-items: center; color: $color; }
.ms__pickIc svg { display: block; }
.ms__pickName {
font-weight: 600; font-size: 10px; line-height: 1.1;
font-family: inherit;
}
.ms__close {
box-sizing: border-box;
height: 32px;
border: 0;
border-radius: $r-control;
background: $sh-cream;
color: $sh-slate; // 크림 위 보조 글자 6.76:1
cursor: pointer;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ms__close:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 열림 상태가 그대로 보인다
.ms.is-open .ms__scrim { opacity: 1; pointer-events: auto; }
.ms.is-open .ms__sheet { transform: translateY(0); }
.ms.is-open .ms__pick { transform: translateY(0); }
.ms.is-open .ms__pick:nth-child(1) { transition-delay: 60ms; }
.ms.is-open .ms__pick:nth-child(2) { transition-delay: 100ms; }
.ms.is-open .ms__pick:nth-child(3) { transition-delay: 140ms; }
.ms.is-open .ms__pick:nth-child(4) { transition-delay: 180ms; }
// ── 자동 재생 ───────────────────────────────────────────────
// 시트는 루프 안에서 닫히지 않는다 — 대표 이미지는 앞 장과 가장 크게 달라진 장이라
// 닫힘을 재현하면 시트 없는 장이 뽑힌다. 되돌림은 루프 경계에서만 일어난다
.is-demo .ms__sheet {
animation-name: msRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 뒷면은 시트가 자리에 선 뒤에 한 프레임으로 어두워진다 — 카드 전체가 한 번에 바뀌므로
// 그 장이 가장 크게 달라진 장이 되고, 대표 이미지가 "반쯤 올라온 시트" 대신 다 선 시트가 된다
// (첫 렌더 실측: 시트가 오르는 중간 장이 뽑혀 칸 넷이 카드 밖으로 잘려 보였다)
.is-demo .ms__scrim {
animation-name: msDim;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 칸 넷이 시트보다 늦게 올라와 프레임을 채운다. 시차에는 fill-mode: backwards 를 같이 준다
.is-demo .ms__pick {
animation-name: msPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ms__pick:nth-child(1) { animation-delay: .14s; }
.is-demo .ms__pick:nth-child(2) { animation-delay: .22s; }
.is-demo .ms__pick:nth-child(3) { animation-delay: .30s; }
.is-demo .ms__pick:nth-child(4) { animation-delay: .38s; }
@keyframes msRise {
0%, 6% { transform: translateY(100%); }
34%, 100% { transform: translateY(0); }
}
@keyframes msDim {
0%, 37.4% { opacity: 0; }
37.5%, 100% { opacity: 1; }
}
@keyframes msPop {
0% { transform: translateY(10px); }
30%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.ms { height: 168px; }
.ms__screen { gap: $sp-1; padding: $sp-3; }
.ms__title { font-size: 13px; }
.ms__row { height: 6px; }
.ms__open { margin-top: 0; height: 32px; padding: 0 $sp-2; }
.ms__sheet { gap: $sp-1; padding: $sp-1 $sp-2 $sp-2; }
.ms__pick { padding: $sp-1 0; }
.ms__pickIc svg { width: 16px; height: 16px; }
.ms__pickName { font-size: 9px; }
.ms__close { height: 28px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ms__sheet, .is-demo .ms__scrim, .is-demo .ms__pick { animation: none !important; }
.ms__sheet, .ms__scrim, .ms__pick { transition: none !important; }
// 움직임을 끊어도 열린 시트와 칸 넷은 제자리에 그대로 선다
.ms.is-open .ms__sheet { transform: translateY(0); }
.ms.is-open .ms__pick { transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms {
position: relative;
box-sizing: border-box;
width: clamp(232px, 72vw, 272px);
height: 216px;
overflow: hidden;
border-radius: 16px;
background: #fdf8f0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ms__screen {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.ms__title {
margin: 0;
color: #15131a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
font-family: inherit;
}
.ms__row {
display: block;
height: 8px;
border-radius: 999px;
background: #e3ddd2;
}
.ms__row--full {
width: 100%;
}
.ms__row--wide {
width: 86%;
}
.ms__row--mid {
width: 60%;
}
.ms__open {
align-self: flex-start;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #15131a;
color: #ffffff;
cursor: pointer;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ms__open:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.ms__ic {
display: grid;
place-items: center;
}
.ms__ic svg {
display: block;
}
.ms__scrim {
position: absolute;
inset: 0;
background: rgba(21, 19, 26, 0.48);
opacity: 0;
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px 12px;
border-radius: 16px 16px 0 0;
background: #ffffff;
transform: translateY(100%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__grip {
align-self: center;
width: 36px;
height: 4px;
border-radius: 999px;
background: #ddd6ca;
}
.ms__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
}
.ms__pick {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
border: 0;
border-radius: 12px;
background: #fdf8f0;
color: #15131a;
cursor: pointer;
transform: translateY(10px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__pick:hover, .ms__pick:focus-visible {
background: #f0e9dd;
}
.ms__pick:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ms__pickIc {
display: grid;
place-items: center;
color: #477690;
}
.ms__pickIc svg {
display: block;
}
.ms__pickName {
font-weight: 600;
font-size: 10px;
line-height: 1.1;
font-family: inherit;
}
.ms__close {
box-sizing: border-box;
height: 32px;
border: 0;
border-radius: 12px;
background: #fdf8f0;
color: #5b5566;
cursor: pointer;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ms__close:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ms.is-open .ms__scrim {
opacity: 1;
pointer-events: auto;
}
.ms.is-open .ms__sheet {
transform: translateY(0);
}
.ms.is-open .ms__pick {
transform: translateY(0);
}
.ms.is-open .ms__pick:nth-child(1) {
transition-delay: 60ms;
}
.ms.is-open .ms__pick:nth-child(2) {
transition-delay: 100ms;
}
.ms.is-open .ms__pick:nth-child(3) {
transition-delay: 140ms;
}
.ms.is-open .ms__pick:nth-child(4) {
transition-delay: 180ms;
}
.is-demo .ms__sheet {
animation-name: msRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .ms__scrim {
animation-name: msDim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ms__pick {
animation-name: msPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ms__pick:nth-child(1) {
animation-delay: 0.14s;
}
.is-demo .ms__pick:nth-child(2) {
animation-delay: 0.22s;
}
.is-demo .ms__pick:nth-child(3) {
animation-delay: 0.3s;
}
.is-demo .ms__pick:nth-child(4) {
animation-delay: 0.38s;
}
@keyframes msRise {
0%, 6% {
transform: translateY(100%);
}
34%, 100% {
transform: translateY(0);
}
}
@keyframes msDim {
0%, 37.4% {
opacity: 0;
}
37.5%, 100% {
opacity: 1;
}
}
@keyframes msPop {
0% {
transform: translateY(10px);
}
30%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ms {
height: 168px;
}
.ms__screen {
gap: 4px;
padding: 12px;
}
.ms__title {
font-size: 13px;
}
.ms__row {
height: 6px;
}
.ms__open {
margin-top: 0;
height: 32px;
padding: 0 8px;
}
.ms__sheet {
gap: 4px;
padding: 4px 8px 8px;
}
.ms__pick {
padding: 4px 0;
}
.ms__pickIc svg {
width: 16px;
height: 16px;
}
.ms__pickName {
font-size: 9px;
}
.ms__close {
height: 28px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ms__sheet, .is-demo .ms__scrim, .is-demo .ms__pick {
animation: none !important;
}
.ms__sheet, .ms__scrim, .ms__pick {
transition: none !important;
}
.ms.is-open .ms__sheet {
transform: translateY(0);
}
.ms.is-open .ms__pick {
transform: translateY(0);
}
}(function () {
var body = document.body;
var root = document.querySelector('.ms');
var open = document.querySelector('.ms__open');
var sheet = document.querySelector('.ms__sheet');
var scrim = document.querySelector('.ms__scrim');
var close = document.querySelector('.ms__close');
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
function focusables() {
return Array.prototype.slice.call(sheet.querySelectorAll('button'));
}
function setOpen(on) {
root.classList.toggle('is-open', on);
sheet.setAttribute('aria-hidden', on ? 'false' : 'true');
focusables().forEach(function (b) { b.tabIndex = on ? 0 : -1; });
if (on) focusables()[0].focus({ preventScroll: true });
else open.focus({ preventScroll: true });
}
open.addEventListener('click', function () { hand(); setOpen(true); });
open.addEventListener('pointerdown', hand);
open.addEventListener('focusin', hand);
close.addEventListener('click', function () { hand(); setOpen(false); });
scrim.addEventListener('click', function () { hand(); setOpen(false); });
// 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다
sheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { hand(); setOpen(false); return; }
if (e.key !== 'Tab') return;
var list = focusables();
var last = list.length - 1;
var at = list.indexOf(document.activeElement);
if (e.shiftKey && at <= 0) { e.preventDefault(); list[last].focus({ preventScroll: true }); }
else if (!e.shiftKey && at === last) { e.preventDefault(); list[0].focus({ preventScroll: true }); }
});
sheet.setAttribute('aria-hidden', 'true');
focusables().forEach(function (b) { b.tabIndex = -1; });
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareMobileSheet.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type FocusBox = { focus(opts: { preventScroll: boolean }): void };
type SheetEl = { querySelectorAll(sel: string): FocusBox[] };
export type ShareMobileSheetProps = {
/** 시트 안 칸 이름 */
picks?: string[];
/** 열림 → 머무름 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 고른 칸 강조 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · QR */
const ICONS: ReactNode[] = [
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<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>,
];
export default function ShareMobileSheet({
picks = ["링크 복사", "메일", "메시지", "QR 코드"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0f5f8a",
autoPlay = true,
}: ShareMobileSheetProps) {
const [demo, setDemo] = useState(autoPlay);
const [open, setOpen] = useState(false);
const sheetRef = useRef<SheetEl>(null) as { current: SheetEl | null };
const openRef = useRef<FocusBox>(null) as { current: FocusBox | null };
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyOpen(on: boolean) {
stopDemo();
setOpen(on);
const list = sheetRef.current ? sheetRef.current.querySelectorAll("button") : [];
if (on && list.length) list[0].focus({ preventScroll: true });
if (!on && openRef.current) openRef.current.focus({ preventScroll: true });
}
/* 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다 */
function applyKey(e: KeyEvt) {
if (e.key === "Escape") {
applyOpen(false);
return;
}
if (e.key !== "Tab" || !sheetRef.current) return;
const list = sheetRef.current.querySelectorAll("button");
const last = list.length - 1;
const at = list.indexOf(document.activeElement as unknown as FocusBox);
if (e.shiftKey && at <= 0) {
e.preventDefault();
list[last].focus({ preventScroll: true });
} else if (!e.shiftKey && at === last) {
e.preventDefault();
list[0].focus({ preventScroll: true });
}
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.phone + (open ? " " + styles.isOpen : "")}>
<div className={styles.screen}>
<p className={styles.title}>오늘의 기록</p>
<span className={styles.row + " " + styles.rowFull} aria-hidden="true" />
<span className={styles.row + " " + styles.rowWide} aria-hidden="true" />
<span className={styles.row + " " + styles.rowMid} aria-hidden="true" />
<button
className={styles.open}
type="button"
onClick={() => applyOpen(true)}
onPointerDown={stopDemo}
onFocus={stopDemo}
ref={openRef as unknown as never}
>
<span className={styles.ic}><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span>공유</span>
</button>
</div>
<div className={styles.scrim} aria-hidden="true" onClick={() => applyOpen(false)} />
<div
className={styles.sheet}
role="dialog"
aria-modal="true"
aria-label="공유 방법 고르기"
aria-hidden={!open}
onKeyDown={applyKey}
ref={sheetRef as unknown as never}
>
<span className={styles.grip} aria-hidden="true" />
<div className={styles.grid}>
{picks.map((name, i) => (
<button key={name} className={styles.pick} type="button" tabIndex={open ? 0 : -1}>
<span className={styles.pickIc}>{ICONS[i]}</span>
<span className={styles.pickName}>{name}</span>
</button>
))}
</div>
<button
className={styles.close}
type="button"
tabIndex={open ? 0 : -1}
onClick={() => applyOpen(false)}
>
닫기
</button>
</div>
</div>
</div>
);
}06 · Mobile share sheet — Measured area 39.759% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Opening moves focus to the first button, Tab wraps from last back to first, Escape and a backdrop click close it, and closing returns focus to the share button.
// Focus trap — while the sheet is open, Tab stays inside it
sheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); return; }
if (e.key !== 'Tab') return;
var list = focusables(); var last = list.length - 1;
var at = list.indexOf(document.activeElement);
if (e.shiftKey && at <= 0) { e.preventDefault(); list[last].focus({ preventScroll: true }); }
else if (!e.shiftKey && at === last) { e.preventDefault(); list[0].focus({ preventScroll: true }); }
});
07Sticky bottom share bar
What you see
To keep the reading flow intact, the bar pins to the bottom of the document with position: sticky; bottom: 0, and pressing the handle or swiping up more than 12px unfolds five icons. The unfolded height is not a fixed pixel but a grid-template-rows: 0fr → 1fr track — height: auto has no intermediate value while 0fr and 1fr do.

<div class="stage">
<div class="sb">
<div class="sb__view">
<div class="sb__doc">
<div class="sb__body">
<p class="sb__title">Share while you read</p>
<span class="sb__row sb__row--full" aria-hidden="true"></span>
<span class="sb__row sb__row--wide" aria-hidden="true"></span>
<span class="sb__row sb__row--mid" aria-hidden="true"></span>
<span class="sb__row sb__row--full" aria-hidden="true"></span>
<span class="sb__row sb__row--short" aria-hidden="true"></span>
</div>
<div class="sb__bar">
<button class="sb__handle" type="button" aria-expanded="false" aria-controls="sb-panel">
<span class="sb__handleName">Share options</span>
<span class="sb__caret"><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,160H48l80-80Z" opacity="0.2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg></span>
</button>
<div class="sb__panel" id="sb-panel">
<div class="sb__inner">
<a class="sb__pick" href="#" aria-label="Copy link"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></a>
<a class="sb__pick" href="#" aria-label="Send by mail"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></a>
<a class="sb__pick" href="#" aria-label="Send in a message"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></a>
<a class="sb__pick" href="#" aria-label="Show QR code"><svg width="18" height="18" 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></a>
<a class="sb__pick" href="#" aria-label="Open in a new window"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,80V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H176A8,8,0,0,1,184,80Z" opacity="0.2"/><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-mist: #bdb7c6;
$sh-deep: #477690;
$sh-sky: #c8dce4; // 잉크 위 강조 11.09:1
$duration: $dur-loop; // 펼침 → 머무름 한 바퀴
$easing: $ease-spring;
$color: $sh-sky; // 호버·초점의 아이콘 색
@include stage($stage-yellow);
.sb {
box-sizing: border-box;
width: clamp(248px, 78vw, 320px);
height: 216px;
overflow: hidden;
border-radius: $r-card;
background: $sh-cream;
box-shadow: $shadow-raised;
}
.sb__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y; // 세로 스크롤은 브라우저에 맡기고 손잡이만 직접 읽는다
}
// 화면보다 긴 문서 — 이 높이 차이가 sticky 가 걸릴 여유다.
// 여유를 padding-bottom 으로 주면 sticky 유효 범위(부모 콘텐츠 박스) 밖이라 안 먹는다
.sb__doc {
display: flex;
flex-direction: column;
min-height: 300px;
}
.sb__body {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-4;
}
.sb__title {
margin: 0;
color: $sh-ink;
font-weight: 700; font-size: 14px; line-height: 1.2;
font-family: inherit;
}
.sb__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: #e3ddd2;
}
.sb__row--full { width: 100%; }
.sb__row--wide { width: 86%; }
.sb__row--mid { width: 62%; }
.sb__row--short { width: 40%; }
.sb__bar {
position: sticky;
bottom: 0;
margin-top: auto;
background: $sh-ink;
}
// 스크롤 0 에서는 sticky 가 안 걸리는 경우가 있어(260-09 실측)
// 자동 재생 동안에는 화면(.sb) 기준 절대배치로 세워 둔다
.is-demo .sb__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.is-demo .sb { position: relative; }
.sb__handle {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 36px;
padding: 0 $sp-3;
border: 0;
background: transparent;
color: $sh-mist; // 잉크 위 보조 글자 9.43:1
cursor: pointer;
touch-action: pan-y;
}
.sb__handle:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: -2px;
}
.sb__handleName {
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.sb__caret {
display: grid;
place-items: center;
transform: rotate(0deg);
transition: transform $dur-base $easing;
}
.sb__caret svg { display: block; }
// 접힌 행의 높이는 고정 px 이 아니라 격자 트랙이다 —
// height: auto 는 중간값이 계산되지 않지만 0fr 과 1fr 사이는 계산된다
.sb__panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.sb__inner {
display: flex;
justify-content: space-between;
gap: $sp-2;
min-height: 0; // 0fr 에서 실제로 접히게 하는 한 줄
padding: 0 $sp-3 $sp-3;
}
.sb__pick {
box-sizing: border-box;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: $r-control;
background: #2b2833;
color: $sh-paper;
text-decoration: none;
}
.sb__pick svg { display: block; }
.sb__pick:hover, .sb__pick:focus-visible { color: $color; }
.sb__pick:focus-visible {
outline: 2px solid $sh-paper;
outline-offset: 2px;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 펼침 상태가 그대로 보인다
.sb.is-open .sb__panel { grid-template-rows: 1fr; }
.sb.is-open .sb__caret { transform: rotate(180deg); }
// ── 자동 재생 ───────────────────────────────────────────────
// 행은 루프 안에서 다시 접히지 않는다 — 대표 이미지는 앞 장과 가장 크게 달라진 장이라
// 접힘을 재현하면 아이콘 없는 장이 뽑힌다. 되돌림은 루프 경계에서만 일어난다
// 행은 한 프레임에 열린다(steps) — 트랙이 서서히 자라면 아이콘이 위쪽만 보이는 장이
// 가장 크게 달라진 장이 되어 대표 이미지가 반쯤 잘린 아이콘 줄이 된다 (첫 렌더 실측).
// 실제 손으로 쓸 때는 위의 transition 이 그대로라 부드럽게 열린다
.is-demo .sb__panel {
animation-name: sbOpen;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sb__caret {
animation-name: sbTurn;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 아이콘 다섯이 행보다 늦게 떠올라 프레임을 채운다. 시차에는 fill-mode: backwards 를 같이 준다
.is-demo .sb__pick {
animation-name: sbPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sb__pick:nth-child(1) { animation-delay: .50s; }
.is-demo .sb__pick:nth-child(2) { animation-delay: .58s; }
.is-demo .sb__pick:nth-child(3) { animation-delay: .66s; }
.is-demo .sb__pick:nth-child(4) { animation-delay: .74s; }
.is-demo .sb__pick:nth-child(5) { animation-delay: .82s; }
@keyframes sbOpen {
0%, 24.9% { grid-template-rows: 0fr; }
25%, 100% { grid-template-rows: 1fr; }
}
@keyframes sbTurn {
0%, 8% { transform: rotate(0deg); }
25%, 100% { transform: rotate(180deg); }
}
@keyframes sbPop {
0% { transform: translateY(8px) scale(.88); }
26%, 100% { transform: translateY(0) scale(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.sb { height: 156px; }
.sb__doc { min-height: 220px; }
.sb__body { gap: $sp-1; padding: $sp-3; }
.sb__title { font-size: 13px; }
.sb__row { height: 6px; }
.sb__handle { height: 32px; padding: 0 $sp-2; }
.sb__inner { gap: $sp-1; padding: 0 $sp-2 $sp-2; }
.sb__pick { width: 32px; height: 32px; }
.sb__pick svg { width: 16px; height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .sb__panel, .is-demo .sb__caret, .is-demo .sb__pick { animation: none !important; }
.sb__panel, .sb__caret { transition: none !important; }
// 움직임을 끊어도 펼친 행과 아이콘은 그대로 남는다
.sb.is-open .sb__panel { grid-template-rows: 1fr; }
.sb__pick { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
box-sizing: border-box;
width: clamp(248px, 78vw, 320px);
height: 216px;
overflow: hidden;
border-radius: 16px;
background: #fdf8f0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sb__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
.sb__doc {
display: flex;
flex-direction: column;
min-height: 300px;
}
.sb__body {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.sb__title {
margin: 0;
color: #15131a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
font-family: inherit;
}
.sb__row {
display: block;
height: 8px;
border-radius: 999px;
background: #e3ddd2;
}
.sb__row--full {
width: 100%;
}
.sb__row--wide {
width: 86%;
}
.sb__row--mid {
width: 62%;
}
.sb__row--short {
width: 40%;
}
.sb__bar {
position: sticky;
bottom: 0;
margin-top: auto;
background: #15131a;
}
.is-demo .sb__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.is-demo .sb {
position: relative;
}
.sb__handle {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 36px;
padding: 0 12px;
border: 0;
background: transparent;
color: #bdb7c6;
cursor: pointer;
touch-action: pan-y;
}
.sb__handle:focus-visible {
outline: 2px solid #ffffff;
outline-offset: -2px;
}
.sb__handleName {
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.sb__caret {
display: grid;
place-items: center;
transform: rotate(0deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sb__caret svg {
display: block;
}
.sb__panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sb__inner {
display: flex;
justify-content: space-between;
gap: 8px;
min-height: 0;
padding: 0 12px 12px;
}
.sb__pick {
box-sizing: border-box;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #2b2833;
color: #ffffff;
text-decoration: none;
}
.sb__pick svg {
display: block;
}
.sb__pick:hover, .sb__pick:focus-visible {
color: #c8dce4;
}
.sb__pick:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sb.is-open .sb__panel {
grid-template-rows: 1fr;
}
.sb.is-open .sb__caret {
transform: rotate(180deg);
}
.is-demo .sb__panel {
animation-name: sbOpen;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sb__caret {
animation-name: sbTurn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .sb__pick {
animation-name: sbPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sb__pick:nth-child(1) {
animation-delay: 0.5s;
}
.is-demo .sb__pick:nth-child(2) {
animation-delay: 0.58s;
}
.is-demo .sb__pick:nth-child(3) {
animation-delay: 0.66s;
}
.is-demo .sb__pick:nth-child(4) {
animation-delay: 0.74s;
}
.is-demo .sb__pick:nth-child(5) {
animation-delay: 0.82s;
}
@keyframes sbOpen {
0%, 24.9% {
grid-template-rows: 0fr;
}
25%, 100% {
grid-template-rows: 1fr;
}
}
@keyframes sbTurn {
0%, 8% {
transform: rotate(0deg);
}
25%, 100% {
transform: rotate(180deg);
}
}
@keyframes sbPop {
0% {
transform: translateY(8px) scale(0.88);
}
26%, 100% {
transform: translateY(0) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.sb {
height: 156px;
}
.sb__doc {
min-height: 220px;
}
.sb__body {
gap: 4px;
padding: 12px;
}
.sb__title {
font-size: 13px;
}
.sb__row {
height: 6px;
}
.sb__handle {
height: 32px;
padding: 0 8px;
}
.sb__inner {
gap: 4px;
padding: 0 8px 8px;
}
.sb__pick {
width: 32px;
height: 32px;
}
.sb__pick svg {
width: 16px;
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sb__panel, .is-demo .sb__caret, .is-demo .sb__pick {
animation: none !important;
}
.sb__panel, .sb__caret {
transition: none !important;
}
.sb.is-open .sb__panel {
grid-template-rows: 1fr;
}
.sb__pick {
transform: none;
}
}(function () {
var body = document.body;
var root = document.querySelector('.sb');
var handle = document.querySelector('.sb__handle');
var picks = Array.prototype.slice.call(document.querySelectorAll('.sb__pick'));
var startY = 0;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
function setOpen(on) {
root.classList.toggle('is-open', on);
handle.setAttribute('aria-expanded', on ? 'true' : 'false');
picks.forEach(function (a) { a.tabIndex = on ? 0 : -1; }); // 접힌 행은 Tab 순서에서 뺀다
}
handle.addEventListener('click', function () {
hand();
setOpen(handle.getAttribute('aria-expanded') !== 'true');
});
handle.addEventListener('focusin', hand);
// 위로 쓸어 올리면 펼쳐지고 아래로 내리면 접힌다 — 세로 스크롤은 그대로 둔다
handle.addEventListener('pointerdown', function (e) { hand(); startY = e.clientY; });
handle.addEventListener('pointerup', function (e) {
var dy = startY - e.clientY;
if (dy > 12) setOpen(true);
else if (dy < -12) setOpen(false);
});
setOpen(false);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareStickyBar.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type ClickEvt = { preventDefault(): void };
type PointerEvt = { clientY: number };
export type ShareStickyBarProps = {
/** 펼쳐지는 아이콘의 접근성 이름 */
labels?: string[];
/** 펼침 → 머무름 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 호버·초점의 아이콘 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · QR · 새 창 */
const ICONS: ReactNode[] = [
<svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<svg width="18" height="18" 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>,
<svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,80V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H176A8,8,0,0,1,184,80Z" opacity="0.2"/><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></svg>,
];
const ROWS = ["rowFull", "rowWide", "rowMid", "rowFull", "rowShort"];
export default function ShareStickyBar({
labels = ["링크 복사", "메일로 보내기", "메시지로 보내기", "QR 코드 보기", "새 창으로 열기"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#7fd4f5",
autoPlay = true,
}: ShareStickyBarProps) {
const [demo, setDemo] = useState(autoPlay);
const [open, setOpen] = useState(false);
const startRef = useRef<number>(0) as { current: number };
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyToggle() {
stopDemo();
setOpen((prev) => !prev);
}
/* 위로 쓸어 올리면 펼쳐지고 아래로 내리면 접힌다 — 세로 스크롤은 그대로 둔다 */
function applyDown(e: PointerEvt) {
stopDemo();
startRef.current = e.clientY;
}
function applyUp(e: PointerEvt) {
const dy = startRef.current - e.clientY;
if (dy > 12) setOpen(true);
else if (dy < -12) setOpen(false);
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.sticky + (open ? " " + styles.isOpen : "")}>
<div className={styles.view}>
<div className={styles.doc}>
<div className={styles.body}>
<p className={styles.title}>읽는 중에도 공유한다</p>
{ROWS.map((cls, i) => (
<span key={i} className={styles.row + " " + styles[cls]} aria-hidden="true" />
))}
</div>
<div className={styles.bar}>
<button
className={styles.handle}
type="button"
aria-expanded={open}
onClick={applyToggle}
onPointerDown={applyDown}
onPointerUp={applyUp}
onFocus={stopDemo}
>
<span className={styles.handleName}>공유 방법</span>
<span className={styles.caret}><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,160H48l80-80Z" opacity="0.2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg></span>
</button>
<div className={styles.panel}>
<div className={styles.inner}>
{labels.map((name, i) => (
<a
key={name}
className={styles.pick}
href="#"
aria-label={name}
tabIndex={open ? 0 : -1}
onClick={(e: ClickEvt) => {
e.preventDefault();
stopDemo();
}}
>
{ICONS[i]}
</a>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}07 · Sticky bottom share bar — Measured area 14.452% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The inner wrapper needs min-height: 0 or the track never truly collapses.
.sb__panel {
display: grid;
grid-template-rows: 0fr; /* collapsed */
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.sb__inner { min-height: 0; } /* the one line that makes 0fr fold */
.sb.is-open .sb__panel { grid-template-rows: 1fr; }
08QR code share popup
What happens on screen
Where hands cannot reach — cards, booths, signs — the screen passes the address to a camera, and this popup's QR is not an image file but a 29×29 module matrix of black and white grid cells, so it never blurs when enlarged and cannot be tinted by mistake. Reading the actually rendered frame back through a camera library returned https://godrichstory.com.

<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 · See the password
How to read the code
The scan line sweeping the code exists only in the preview.
// A 29×29 module matrix — black-cell coordinates, not an image.
// The full 29 rows ship in the zip's original file.
var rows = [
"00000000000000000000000000000",
"00111111101100010010111111100",
"00100000100000010000100000100"
];
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);
09Email and embed share box
What moves
When the recipient is decided, mail lands directly: the mail tab's link is a mailto: with subject and body percent-encoded, so pressing it opens a mostly written email, while the embed tab offers a read-only code field and a copy button. Tabs switch by click and by the arrow keys.

<div class="stage">
<section class="ee" aria-label="Share by mail or embed">
<div class="ee__tabs" role="tablist" aria-label="Share options">
<button class="ee__tab is-on" type="button" role="tab" id="ee-t1" aria-controls="ee-p1" aria-selected="true">Mail</button>
<button class="ee__tab" type="button" role="tab" id="ee-t2" aria-controls="ee-p2" aria-selected="false" tabindex="-1">Embed</button>
<span class="ee__line" aria-hidden="true"></span>
</div>
<div class="ee__panels">
<div class="ee__panel ee__panel--mail" role="tabpanel" id="ee-p1" aria-labelledby="ee-t1">
<p class="ee__label">The message your reader gets</p>
<a class="ee__mail" href="mailto:?subject=%EA%B3%B5%EC%9C%A0%ED%95%A9%EB%8B%88%EB%8B%A4&body=https%3A%2F%2Fexample.com%2Fposts%2Fshare-ui">
<span class="ee__mailIc"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg></span>
<span class="ee__mailName">Open in your mail app</span>
</a>
</div>
<div class="ee__panel ee__panel--embed" role="tabpanel" id="ee-p2" aria-labelledby="ee-t2" hidden>
<p class="ee__label">Code to paste as is</p>
<div class="ee__field">
<input class="ee__code" type="text" value="<iframe src="https://example.com/embed"></iframe>" readonly aria-label="Code to paste">
<button class="ee__copy" type="button" aria-label="Copy code"><svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg></button>
</div>
</div>
</div>
</section>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-deep: #477690; // 흰 면 위 강조 6.94:1
$duration: $dur-loop; // 두 탭을 오가는 한 바퀴
$easing: $ease-spring;
$color: $sh-deep; // 밑줄과 강조 색
@include stage($stage-orange);
.ee {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: clamp(280px, 86vw, 384px);
padding: $sp-4;
border-radius: $r-card;
background: $sh-paper;
box-shadow: $shadow-raised;
}
// 칸을 격자로 나눈다 — 밑줄이 짐작한 px 이 아니라 100% 단위로 움직인다 (BD19)
.ee__tabs {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px solid #ddd6ca;
}
.ee__tab {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-2;
border: 0;
background: transparent;
color: $sh-slate; // 흰 면 위 보조 글자 7.15:1
cursor: pointer;
font-weight: 700; font-size: 13px; line-height: 1;
font-family: inherit;
}
.ee__tab.is-on { color: $sh-ink; }
.ee__tab:focus-visible {
outline: 2px solid $color;
outline-offset: -2px;
}
.ee__line {
position: absolute;
left: 0;
bottom: -1px;
width: 50%; // 칸 하나 = 100% → translateX(100%) 이 정확히 한 칸
height: 2px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform $dur-base $easing;
}
.ee__panels { display: grid; }
.ee__panel {
display: flex;
flex-direction: column;
gap: $sp-2;
}
// 클래스에 display 를 주면 [hidden] 의 기본 display:none 을 이겨 두 판이 함께 보인다 —
// 숨김 규칙을 다시 적어 줘야 한다 (정적 상태 스냅샷 실측)
.ee__panel[hidden] { display: none; }
.ee__label {
margin: 0;
color: $sh-slate;
font-weight: 600; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ee__mail {
box-sizing: border-box;
align-self: flex-start;
display: flex;
align-items: center;
gap: $sp-2;
height: 40px;
padding: 0 $sp-4;
border-radius: $r-control;
background: $sh-ink;
color: $sh-paper; // 잉크 면 위 흰 글자 18.42:1
text-decoration: none;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ee__mail:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
.ee__mailIc { display: grid; place-items: center; }
.ee__mailIc svg { display: block; }
.ee__field {
display: flex;
gap: $sp-2;
}
.ee__code {
flex: 1 1 auto;
box-sizing: border-box;
min-width: 0;
height: 40px;
padding: 0 $sp-3;
border: 1px solid #ddd6ca;
border-radius: $r-control;
background: $sh-cream;
color: $sh-ink;
font-weight: 500; font-size: 11px; line-height: 1;
font-family: inherit;
}
.ee__code:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
.ee__copy {
flex: 0 0 auto;
box-sizing: border-box;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: $r-control;
background: $sh-ink;
color: $sh-paper;
cursor: pointer;
}
.ee__copy svg { display: block; }
.ee__copy:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 미리보기 동안에는 두 판을 같은 격자 칸에 포개고 한 프레임에 컷으로 갈아 끼운다.
// 겹쳐 두는 쪽이 판을 바꿔도 상자 높이가 안 흔들린다
.is-demo .ee__panel { grid-area: 1 / 1; }
.is-demo .ee__panel[hidden] { display: flex; }
.is-demo .ee__line {
animation-name: eeSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 판과 라벨 교체는 steps(1, end) 컷 — 두 판이 겹친 반투명 유령 장이 안 생긴다
.is-demo .ee__panel, .is-demo .ee__tab {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ee__panel--mail { animation-name: eeShowA; }
.is-demo .ee__panel--embed { animation-name: eeShowB; }
.is-demo .ee__tab:nth-child(1) { animation-name: eeOnA; }
.is-demo .ee__tab:nth-child(2) { animation-name: eeOnB; }
// 컷만 있으면 움직인 프레임이 두 장뿐이다 — 컷 옆에 투명도가 아닌 transform 움직임을 붙인다.
// 시차에는 fill-mode: backwards 를 같이 준다
.is-demo .ee__mail, .is-demo .ee__field {
animation-name: eePop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ee__field { animation-delay: 1s; }
@keyframes eeSlide {
0%, 4% { transform: translateX(0); }
46%, 50% { transform: translateX(100%); }
92%, 100% { transform: translateX(0); }
}
// 컷 시각은 24프레임 눈금(4.1667% 간격)에 맞춘다 — 밑줄이 그 칸에 닿은 뒤에 판을 바꾼다
@keyframes eeShowA { 0%, 49.9% { visibility: visible; } 50%, 95.7% { visibility: hidden; } 95.8%, 100% { visibility: visible; } }
@keyframes eeShowB { 0%, 49.9% { visibility: hidden; } 50%, 95.7% { visibility: visible; } 95.8%, 100% { visibility: hidden; } }
@keyframes eeOnA { 0%, 49.9% { color: $sh-ink; } 50%, 95.7% { color: $sh-slate; } 95.8%, 100% { color: $sh-ink; } }
@keyframes eeOnB { 0%, 49.9% { color: $sh-slate; } 50%, 95.7% { color: $sh-ink; } 95.8%, 100% { color: $sh-slate; } }
@keyframes eePop {
0% { transform: translateY(10px) scale(.96); }
14%, 50% { transform: translateY(0) scale(1); }
50.1%, 100% { transform: translateY(0) scale(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.ee { gap: $sp-2; padding: $sp-3; }
.ee__tab { height: 32px; font-size: 12px; }
.ee__mail { height: 36px; padding: 0 $sp-3; font-size: 11px; }
.ee__code { height: 36px; padding: 0 $sp-2; font-size: 10px; }
.ee__copy { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ee__line, .is-demo .ee__panel, .is-demo .ee__tab,
.is-demo .ee__mail, .is-demo .ee__field { animation: none !important; }
.ee__line { transition: none !important; }
// 움직임을 끊어도 고른 탭과 그 판은 그대로 보인다
.is-demo .ee__panel[hidden] { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ee {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: clamp(280px, 86vw, 384px);
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ee__tabs {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px solid #ddd6ca;
}
.ee__tab {
box-sizing: border-box;
height: 36px;
padding: 0 8px;
border: 0;
background: transparent;
color: #5b5566;
cursor: pointer;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.ee__tab.is-on {
color: #15131a;
}
.ee__tab:focus-visible {
outline: 2px solid #477690;
outline-offset: -2px;
}
.ee__line {
position: absolute;
left: 0;
bottom: -1px;
width: 50%;
height: 2px;
border-radius: 999px;
background: #477690;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ee__panels {
display: grid;
}
.ee__panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.ee__panel[hidden] {
display: none;
}
.ee__label {
margin: 0;
color: #5b5566;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ee__mail {
box-sizing: border-box;
align-self: flex-start;
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: 12px;
background: #15131a;
color: #ffffff;
text-decoration: none;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ee__mail:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.ee__mailIc {
display: grid;
place-items: center;
}
.ee__mailIc svg {
display: block;
}
.ee__field {
display: flex;
gap: 8px;
}
.ee__code {
flex: 1 1 auto;
box-sizing: border-box;
min-width: 0;
height: 40px;
padding: 0 12px;
border: 1px solid #ddd6ca;
border-radius: 12px;
background: #fdf8f0;
color: #15131a;
font-weight: 500;
font-size: 11px;
line-height: 1;
font-family: inherit;
}
.ee__code:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ee__copy {
flex: 0 0 auto;
box-sizing: border-box;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 12px;
background: #15131a;
color: #ffffff;
cursor: pointer;
}
.ee__copy svg {
display: block;
}
.ee__copy:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.is-demo .ee__panel {
grid-area: 1/1;
}
.is-demo .ee__panel[hidden] {
display: flex;
}
.is-demo .ee__line {
animation-name: eeSlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .ee__panel, .is-demo .ee__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ee__panel--mail {
animation-name: eeShowA;
}
.is-demo .ee__panel--embed {
animation-name: eeShowB;
}
.is-demo .ee__tab:nth-child(1) {
animation-name: eeOnA;
}
.is-demo .ee__tab:nth-child(2) {
animation-name: eeOnB;
}
.is-demo .ee__mail, .is-demo .ee__field {
animation-name: eePop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ee__field {
animation-delay: 1s;
}
@keyframes eeSlide {
0%, 4% {
transform: translateX(0);
}
46%, 50% {
transform: translateX(100%);
}
92%, 100% {
transform: translateX(0);
}
}
@keyframes eeShowA {
0%, 49.9% {
visibility: visible;
}
50%, 95.7% {
visibility: hidden;
}
95.8%, 100% {
visibility: visible;
}
}
@keyframes eeShowB {
0%, 49.9% {
visibility: hidden;
}
50%, 95.7% {
visibility: visible;
}
95.8%, 100% {
visibility: hidden;
}
}
@keyframes eeOnA {
0%, 49.9% {
color: #15131a;
}
50%, 95.7% {
color: #5b5566;
}
95.8%, 100% {
color: #15131a;
}
}
@keyframes eeOnB {
0%, 49.9% {
color: #5b5566;
}
50%, 95.7% {
color: #15131a;
}
95.8%, 100% {
color: #5b5566;
}
}
@keyframes eePop {
0% {
transform: translateY(10px) scale(0.96);
}
14%, 50% {
transform: translateY(0) scale(1);
}
50.1%, 100% {
transform: translateY(0) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ee {
gap: 8px;
padding: 12px;
}
.ee__tab {
height: 32px;
font-size: 12px;
}
.ee__mail {
height: 36px;
padding: 0 12px;
font-size: 11px;
}
.ee__code {
height: 36px;
padding: 0 8px;
font-size: 10px;
}
.ee__copy {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ee__line, .is-demo .ee__panel, .is-demo .ee__tab,
.is-demo .ee__mail, .is-demo .ee__field {
animation: none !important;
}
.ee__line {
transition: none !important;
}
.is-demo .ee__panel[hidden] {
display: none;
}
}(function () {
var body = document.body;
var root = document.querySelector('.ee');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.ee__tab'));
var panes = Array.prototype.slice.call(document.querySelectorAll('.ee__panel'));
var at = 0;
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
// 밑줄은 짐작한 px 이 아니라 칸 번호 하나로만 움직인다 — 칸 폭이 바뀌어도 안 어긋난다
function move(i, focus) {
at = (i + tabs.length) % tabs.length;
tabs.forEach(function (t, k) {
var on = k === at;
t.classList.toggle('is-on', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1; // 로빙 탭인덱스
panes[k].hidden = !on;
});
root.style.setProperty('--n', at);
if (focus) tabs[at].focus({ preventScroll: true });
}
tabs.forEach(function (t, k) {
t.addEventListener('click', function () { hand(); move(k, true); });
});
root.querySelector('.ee__tabs').addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
hand();
move(at + d, true);
});
root.addEventListener('pointerdown', hand);
root.addEventListener('focusin', hand);
document.querySelector('.ee__copy').addEventListener('click', function () {
var field = document.querySelector('.ee__code');
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(field.value);
else field.select();
});
move(0, false);
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShareEmailEmbed.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type InputEl = { value: string; select(): void };
export type ShareEmailEmbedProps = {
/** 공유할 주소와 심을 코드 */
url?: string;
embedCode?: string;
/** 두 탭을 오가는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 밑줄과 강조 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--n"?: number;
};
const TABS = ["메일", "퍼가기"];
export default function ShareEmailEmbed({
url = "https://example.com/posts/share-ui",
embedCode = '<iframe src="https://example.com/embed"></iframe>',
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0f5f8a",
autoPlay = true,
}: ShareEmailEmbedProps) {
const [demo, setDemo] = useState(autoPlay);
const [at, setAt] = useState(0);
const codeRef = useRef<InputEl>(null) as { current: InputEl | null };
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
/* 밑줄은 짐작한 px 이 아니라 칸 번호 하나로만 움직인다 */
function applyMove(i: number) {
stopDemo();
setAt((i + TABS.length) % TABS.length);
}
function applyKey(e: KeyEvt) {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
applyMove(at + d);
}
function applyCopy() {
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(embedCode);
else if (codeRef.current) codeRef.current.select();
}
const vars: Vars = {
"--duration": duration + "ms",
"--easing": easing,
"--color": color,
"--n": at,
};
const mailHref =
"mailto:?subject=" + encodeURIComponent("공유합니다") + "&body=" + encodeURIComponent(url);
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<section className={styles.embed} aria-label="메일과 퍼가기로 공유" style={vars}>
<div className={styles.tabs} role="tablist" aria-label="공유 방법" onKeyDown={applyKey}>
{TABS.map((name, i) => (
<button
key={name}
className={styles.tab + (at === i ? " " + styles.isOn : "")}
type="button"
role="tab"
id={"ee-t" + i}
aria-controls={"ee-p" + i}
aria-selected={at === i}
tabIndex={at === i ? 0 : -1}
onClick={() => applyMove(i)}
onPointerDown={stopDemo}
>
{name}
</button>
))}
<span className={styles.line} aria-hidden="true" />
</div>
<div className={styles.panels}>
<div
className={styles.panel + " " + styles.panelMail}
role="tabpanel"
id="ee-p0"
aria-labelledby="ee-t0"
hidden={at !== 0}
>
<p className={styles.label}>받는 사람에게 갈 편지</p>
<a className={styles.mail} href={mailHref}>
<span className={styles.mailIc}><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg></span>
<span className={styles.mailName}>메일 앱으로 열기</span>
</a>
</div>
<div
className={styles.panel + " " + styles.panelEmbed}
role="tabpanel"
id="ee-p1"
aria-labelledby="ee-t1"
hidden={at !== 1}
>
<p className={styles.label}>그대로 붙이는 코드</p>
<div className={styles.field}>
<input
className={styles.code}
type="text"
defaultValue={embedCode}
readOnly
aria-label="붙여 넣을 코드"
ref={codeRef as unknown as never}
/>
<button className={styles.copy} type="button" aria-label="코드 복사" onClick={applyCopy}>
<svg width="16" height="16" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg>
</button>
</div>
</div>
</div>
</section>
</div>
);
}09 · Email and embed share box — Measured area 13.145% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The underline travels by cell width, translateX(calc(var(--n) * 100%)).
// The underline moves by cell index only — never by a guessed px
function move(i) {
at = (i + tabs.length) % tabs.length;
tabs.forEach(function (t, k) {
t.classList.toggle('is-on', k === at);
t.setAttribute('aria-selected', k === at ? 'true' : 'false');
t.tabIndex = k === at ? 0 : -1; // roving tabindex
panes[k].hidden = k !== at;
});
root.style.setProperty('--n', at);
}
Where it breaks — traps
The first break was the poster frame. In the count-up, the moment the numbers reach their goals (128, 64, 39) and the moment the badges pop must never share a sample cell: twenty-four frames slice two seconds at 4.1667% intervals, and when both events land in one cell the "still counting" frame wins as the biggest change. The shipped keyframes seat the numbers at 50% and pop the badges at 58% — an 8% gap, two whole sample cells — so the poster shows the full values.
Second was the grid column of the side rail. Sizing the rail's column at the collapsed 44px meant the unfolding label climbed on top of the article title — the column had to reserve the full unfolded width. In the same file the collapsed cut was 92px, which left 44px visible — past the 40px where the label starts, letting its first letter peek; widening the cut to 100px pushed the visible 36px back inside the 40px and closed it.
Third was the static state. Three of the nine that looked fine in the .is-demo loop came up blank or doubled once the loop was lifted — 09 had two panels visible at once because the class set display: flex and beat [hidden]'s default display: none, 08 kept its scan line on the hand path, and 03 had no icon on the failure notice. An .ee__panel[hidden] { display: none } redeclaration, a default opacity: 0 on the scan line, and an added cross-mark icon fixed each. Measurement and eyeballing only ever see the loop state — that lesson is recorded here.
Last was the sheet backdrop in 06. A linear fade during the rise let a half-raised, clipped sheet win as the poster frame. Making the backdrop switch in one frame with steps(1, end) after the sheet settles turned the whole-card change into the biggest difference, and the poster became the fully raised sheet with all four cells standing. When the count-up's landing-and-popping motion needs real weight behind it, physics simulation works the same problem with springs and inertia.
Accessibility
Color pairs were computed directly with the WCAG 2.1 relative luminance formula rather than picked by feel; the two rows at the bottom are the pairs this palette refuses to use.
| Foreground | Background | Measured | Body-text bar |
|---|---|---|---|
| ink #15131a | white #ffffff | 18.42:1 | pass |
| ink #15131a | cream #fdf8f0 | 17.42:1 | pass |
| ink #15131a | sky #7fd4f5 | 11.09:1 | pass |
| slate #5b5566 | white #ffffff | 7.15:1 | pass |
| slate #5b5566 | cream #fdf8f0 | 6.76:1 | pass |
| white #ffffff | ink #15131a | 18.42:1 | pass |
| mist #bdb7c6 | ink #15131a | 9.43:1 | pass |
| white #ffffff | deep blue #0f5f8a | 6.94:1 | pass |
| mint #78e0b4 | ink #15131a | 11.51:1 | pass |
| apricot #ffb3a0 | ink #15131a | 10.71:1 | pass |
| ink #15131a | orange #ff4d1f | 5.56:1 | pass |
| white #ffffff | orange #ff4d1f | 3.32:1 | fails — never used |
| white #ffffff | yellow #ffd23f | 1.44:1 | fails — never used |
The nine sets re-rendered with only the passing pairs are in the zip exactly as measured, and the password that opens it is xbet8bb8. Under prefers-reduced-motion: reduce all nine turn off motion and keep state and information. 01 keeps its ring on hover and focus and aria-pressed announcing the pick. 02 stays folded with hover and focus still unfolding labels. 03 reports the copy result through the toast the moment it happens. 04 jumps straight to the declared final values. 05 and 07 sit collapsed while opening still responds instantly. 06 stays open with the focus trap working. 08 keeps the code standing and hides only the scan line. 09 keeps the chosen tab and its panel visible.
The structural devices actually in place: every icon button has an accessible name (text where the label shows, aria-label where only the icon does), the toggle in 01 uses aria-pressed, the openers in 05 and 07 carry aria-expanded with aria-controls, the sheet in 06 is a role="dialog" with aria-modal, a focus trap, and Escape, and the popup in 08 carries the same role. The badges in 04 are decorative aria-hidden with the counts spoken inside each button's accessible name. 09 is a role="tablist" with a roving tabindex so the keyboard alone moves between tabs, and its code field announces that it is read-only. The outcome sentence in 03 lives in a single role="status" with aria-live="polite" so it is read exactly once, in place, for people who cannot see the screen. Copy and dialog guidance follows MDN's Clipboard API docs and the ARIA dialog pattern; contrast against W3C's minimum contrast explanation. For notifications during reading, continue to the toast stack, and for a bar you operate from the bottom of the screen see the floating dock navigation. How this site builds and checks its code is on the about page.
FAQ
How many social media buttons should I keep?
Three or four. Four that each do a different job — link, mail, message, QR — as in 01 is plenty; eight platform logos in a row means none of them get pressed. Where space is tight, fold them behind one button as in 05. Pulling platform names and logos in wholesale is also worth avoiding — using trademarks and brand colors uninvited is borrowing trouble. The hinge on the vessel that folds and rises — QR popup, share sheet — gets its own treatment in modal animation patterns.
How do I know the copy actually happened?
Ask the browser. navigator.clipboard.writeText returns a promise, so receive success and failure separately as in 03 and report the outcome in words, with a cross mark for failure. Clipboards do get silently blocked by private modes and permission settings, so an announcement that assumes success becomes a lie. A fallback for browsers without the API at all costs one more branch.
Can't I just drop the QR in as an image?
You can, but this collection does not. Drawing the module matrix as a grid like 08 means it stays crisp at any size, cannot be tinted by mistake, and needs no extra file to carry around. Whichever way you build it, the rules hold — keep the black-on-white contrast, overlay nothing on top, and give the grid an aria-label naming what it holds — 08 uses "QR code holding the URL" for exactly that.









