공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는
공유하기 버튼은 방문자가 누른 한 번으로 글을 밖으로 내보내는 마지막 문입니다. 여기 모은 아홉 가지는 겉만 그려 둔 게 아니라 누르면 진짜 복사가 되는 공유 UI 를 HTML·CSS·자바스크립트로 짜 둔 것이라, 그대로 복사해 붙이면 바로 움직입니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 한 줄 공유 줄
- 02 본문 옆 세로 공유 바
- 03 링크 복사 토스트
- 04 공유 수 카운트업
- 05 펼쳐지는 아이콘 공유 버튼
- 06 모바일 공유 시트
- 07 하단 고정 공유 바
- 08 QR 코드 공유 팝업
- 09 메일·퍼가기 공유 박스
아홉 개는 공유하기 버튼 만들기의 유행 순서가 아니라 방문자가 공유를 만나는 순서로 놓았습니다. 글을 다 읽고 만나는 한 줄 공유 줄(01)로 시작해, 읽는 도중에 만나는 세로 바(02)와 누르자마자 결과를 알려 주는 복사 토스트(03)로 이어집니다. 신뢰를 보여 주는 공유 수 카운트업(04)을 지나면 자리를 아끼는 방법 둘 — 버튼 하나에서 펼쳐지는 아이콘(05)과 하단 고정 바(07)가 나오고, 폰 화면에서는 아래에서 올라오는 공유 시트(06)가 그 몫을 대신합니다. 마지막 둘은 공유의 끝을 닫습니다 — 오프라인을 잇는 QR 팝업(08)과 받는 사람을 직접 고르는 메일·퍼가기 박스(09). 아홉 개가 잉크 #15131a · 크림 #fdf8f0 · 하늘 #7fd4f5 라는 같은 색 값을 공유하므로 두셋을 섞어 써도 색이 어긋나지 않습니다. 공유가 끝난 뒤 길을 도려주는 일은 브레드크럼 UI가, 손이 화면을 떠나지 않고 일을 끝내는 축은 키보드 단축키 UI가 이어 받습니다.
01한 줄 공유 줄
공유하기 버튼 ui 에서 무엇이 보이나
글 카드 맨 아래에 라벨과 44px 원형 아이콘 버튼 넷이 한 줄로 서는, 가장 먼저 만들게 되는 모양입니다. 호버·초점에서 버튼이 6px 들리고 겹쳐 둔 원이 scale(.72) 에서 scale(1) 로 차오르는데, 44px 는 손가락이 닿는 최소 면적입니다.

<div class="stage">
<section class="ir" aria-label="글 공유">
<p class="ir__title">이번 주 데모 잡지</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">이 글 공유하기</p>
<div class="ir__btns" role="group" aria-label="공유 방법">
<button class="ir__btn" type="button" aria-pressed="false" aria-label="링크 복사">
<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="이메일로 보내기">
<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="메시지로 보내기">
<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="QR 코드 보기">
<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 · 한 줄 공유 줄 — 측정 누적면적 4.712% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
누른 버튼은 aria-pressed 로 눌린 채 남아 무엇을 골랐는지 보여 줍니다.
/* 링은 테두리가 아니라 겹쳐 둔 원 — 커져도 레이아웃을 다시 계산하지 않는다 */
.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;
}
02본문 옆 세로 공유 바
화면에서 일어나는 일
매거진처럼 글이 긴 사이트에서 공유 바는 본문 옆에 세로로 붙고 position: sticky 로 스크롤을 따라갑니다. 버튼은 평소에 라벨을 감추고 있다가 호버·초점에서 옆으로 펼치는데, 펼침은 너비를 늘리는 게 아니라 clip-path: inset(0 100px 0 0 round 999px) 로 잘라 둔 오른쪽을 드러내는 것이라 문서가 다시 계산되지 않습니다.

<div class="stage">
<div class="sr">
<div class="sr__view">
<div class="sr__doc">
<nav class="sr__rail" aria-label="공유 바">
<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">링크 복사</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">메일 보내기</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">메시지</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">더 보기</span>
</a>
</nav>
<article class="sr__body">
<h2 class="sr__title">긴 글은 옆에서 공유한다</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 · 본문 옆 세로 공유 바 — 측정 누적면적 15.565% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
잘라 둔 뒤 남는 36px 이 라벨 첫 글자가 시작되는 40px(여백 12px+아이콘 20px+간격 8px)보다 작아서 접힌 상태에 글자가 비치지 않습니다.
/* 펼친 폭(136px)으로 상자를 미리 잡고 오른쪽 100px 을 잘라 둔다 */
.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);
}
03링크 복사 토스트
어떤 움직임인가
공유의 절반은 복사라서 버튼을 누르면 navigator.clipboard.writeText 로 주소가 실제로 복사되고, 아래에서 올라온 토스트가 결과를 알립니다. 클립보드 API 가 없으면 입력칸을 선택해 "직접 복사해 주세요" 로 안내하고, 브라우저가 막아 실패한 경우에도 확인 표시 대신 가위표를 띄웁니다 — 실패에 체크를 보여 주면 거짓말이 되기 때문입니다.

<div class="stage">
<section class="ct" aria-label="링크 복사">
<p class="ct__label">공유 주소</p>
<div class="ct__field">
<input class="ct__url" type="text" value="https://example.com/posts/share-ui" readonly aria-label="공유 주소">
<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">복사</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">주소를 복사했습니다</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('주소를 복사했습니다', false); },
function () { show('복사가 막혔습니다. 직접 선택해 주세요', true); }
);
return;
}
input.select(); // 클립보드 API 가 없는 환경의 대비책
show('주소를 선택했습니다. 직접 복사해 주세요', 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 · 링크 복사 토스트 — 측정 누적면적 19.812% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
결과 문장은 role="status" 한 칸이 읽습니다.
function show(msg, failed) {
text.textContent = msg;
root.classList.toggle('is-bad', !!failed); // 막힌 경우엔 확인 표시 대신 가위표
root.classList.add('is-done');
}
navigator.clipboard.writeText(url).then(
function () { show('주소를 복사했습니다', false); },
function () { show('복사가 막혔습니다. 직접 선택해 주세요', true); }
);
04공유 수 카운트업
무엇이 보이나
몇 번 공유됐는지 보여 주는 배지는 신뢰의 숫자인데, 0 에서 목표까지 오르는 데 자바스크립트 카운터가 필요하지 않습니다 — @property 로 타입을 등록한 사용자 지정 속성과 카운터가 그 자리에서 굴립니다. 등록 없이 쓰면 타입이 없어 보간이 안 되고 중간에 한 번 툭 바뀔 뿐이라는 점이 이 항목의 핵심입니다.

<div class="stage">
<section class="cb" aria-label="공유 수">
<p class="cb__title">이번 주 많이 나간 글</p>
<div class="cb__btns">
<button class="cb__btn" type="button" aria-label="링크 복사, 공유 128회">
<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">링크</span>
<span class="cb__badge cb__badge--a" aria-hidden="true"></span>
</button>
<button class="cb__btn" type="button" aria-label="메일로 보내기, 공유 64회">
<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">메일</span>
<span class="cb__badge cb__badge--b" aria-hidden="true"></span>
</button>
<button class="cb__btn" type="button" aria-label="메시지로 보내기, 공유 39회">
<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">메시지</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회 더 나갔습니다</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 · 공유 수 카운트업 — 측정 누적면적 1.206% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
값은 inherits: false 로 등록했으니 배지 요소 자신에게 걸어야 합니다.
@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; } /* .is-demo 를 뗀 상태의 최종값 */
05펼쳐지는 아이콘 공유 버튼
화면에서 일어나는 일
헤더가 좁은 원페이지에는 공유 버튼 하나만 두고, 누르면 플랫폼 버튼 넷이 시차를 두고 옆으로 펼쳐집니다. 접힘은 폭을 0 으로 줄이는 게 아니라 왼쪽 끝에서 clip-path 로 완전히 잘라 두는 것이라 자리를 미리 잡아 두니 펼쳐도 옆 글자가 밀리지 않습니다.
<div class="stage">
<section class="ex" aria-label="공유 펼치기">
<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">공유</span>
</button>
<div class="ex__list" id="ex-list">
<a class="ex__item" href="#" aria-label="링크 복사"><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="메일로 보내기"><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="메시지로 보내기"><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="새 창으로 열기"><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">좁은 자리에서는 버튼 하나만 둡니다</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 · 펼쳐지는 아이콘 공유 버튼 — 측정 누적면적 5.952% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
접힌 동안 버튼은 tabIndex -1 로 Tab 순서에서 빠지고 Esc 로 접히면 초점이 공유 버튼으로 돌아옵니다.
.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; }
06모바일 공유 시트
어떤 움직임인가
폰에서는 화면 아래에서 올라오는 시트가 익숙한 모양이라, 공유 버튼을 누르면 role="dialog" 시트가 올라오고 뒷면이 어두워지며 칸 넷은 시트보다 늦게 떠오릅니다. 접근성의 뼈대는 초점 가두기입니다.

<div class="stage">
<div class="ms">
<div class="ms__screen">
<p class="ms__title">오늘의 기록</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>공유</span>
</button>
</div>
<div class="ms__scrim" aria-hidden="true"></div>
<div class="ms__sheet" role="dialog" aria-modal="true" aria-label="공유 방법 고르기">
<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">링크 복사</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">메일</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">메시지</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 코드</span>
</button>
</div>
<button class="ms__close" type="button">닫기</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 · 모바일 공유 시트 — 측정 누적면적 39.759% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
열면 첫 버튼으로 초점이 옮겨 가고 Tab 이 마지막에서 첫 번째로 돌아오며 Esc 와 뒷면 클릭으로 닫히고, 닫힌 뒤에는 초점이 공유 버튼으로 돌아옵니다.
// 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다
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 }); }
});
07하단 고정 공유 바
무엇이 보이나
읽는 흐름을 끊지 않으려면 공유 바를 화면 아래에 붙이는데, 바는 문서 안에서 position: sticky; bottom: 0 으로 붙고 손잡이를 누르거나 위로 12px 이상 쓸어 올리면 아이콘 다섯이 펼쳐집니다. 펼침의 높이는 고정 픽셀이 아니라 grid-template-rows: 0fr → 1fr 격자 트랙입니다 — height: auto 는 중간값이 계산되지 않지만 0fr 과 1fr 사이는 계산됩니다.

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

<div class="stage">
<section class="ee" aria-label="메일과 퍼가기로 공유">
<div class="ee__tabs" role="tablist" aria-label="공유 방법">
<button class="ee__tab is-on" type="button" role="tab" id="ee-t1" aria-controls="ee-p1" aria-selected="true">메일</button>
<button class="ee__tab" type="button" role="tab" id="ee-t2" aria-controls="ee-p2" aria-selected="false" tabindex="-1">퍼가기</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">받는 사람에게 갈 편지</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">메일 앱으로 열기</span>
</a>
</div>
<div class="ee__panel ee__panel--embed" role="tabpanel" id="ee-p2" aria-labelledby="ee-t2" hidden>
<p class="ee__label">그대로 붙이는 코드</p>
<div class="ee__field">
<input class="ee__code" type="text" value="<iframe src="https://example.com/embed"></iframe>" readonly aria-label="붙여 넣을 코드">
<button class="ee__copy" type="button" aria-label="코드 복사"><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 · 메일·퍼가기 공유 박스 — 측정 누적면적 13.145% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
밑줄은 칸 폭 단위(translateX(calc(var(--n) * 100%)))로 옮겨갑니다.
// 밑줄은 짐작한 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; // 로빙 탭인덱스
panes[k].hidden = k !== at;
});
root.style.setProperty('--n', at);
}
어디서 깨지는가 — 함정
첫 판에서 깨진 것은 대표 이미지였습니다. 04 카운트업에서 숫자가 목표(128·64·39)에 닿는 순간과 배지가 튀는 순간은 같은 샘플 칸에 들면 안 됩니다. 24프레임은 2초를 4.1667% 간격으로 자르는데, 두 사건이 한 칸에 몰리면 "아직 센 중간 숫자"를 찍은 장의 변화량이 가장 커 그 장이 대표로 뽑히기 때문입니다. 지금 소스는 숫자가 50% 에 목표에 닿고 58% 에 튐을 둘러 두 사건을 8% 간격, 샘플 두 칸으로 떼어 놓아, 값이 다 찬 뒤의 배지 튐이 대표 이미지가 되게 했습니다.
두 번째는 02 세로 바의 칸 폭이었습니다. 바가 들어갈 격자 칸을 접힌 폭 44px 로 잡았더니 펼치는 순간 라벨이 본문 제목 위로 얹혔습니다 — 펼친 폭 전체를 칸으로 미리 잡아야 했습니다. 같은 파일에서 접힌 상태의 잘라 둘 폭도 92px 로 잡았다가 라벨 첫 글자의 왼쪽 4px 이 비쳤고, 잘라 둘 폭을 100px 로 넓혀 남는 폭(36px)이 라벨 시작점(40px)보다 작아지게 해결했습니다.
세 번째는 정적 상태였습니다. 아홉 개는 .is-demo 루프에서는 멀쩡했는데 그중 셋은 루프를 뗀 상태가 비거나 겹쳤습니다 — 09 는 판에 display: flex 를 클래스로 줘서 [hidden] 의 기본 display: none 을 이겨 두 판이 함께 보였고, 08 은 손으로 볼 때 스캔 선이 남아 있었으며, 03 은 실패 안내에 아이콘이 없었습니다. .ee__panel[hidden] { display: none } 재선언, 스캔 선의 opacity: 0 기본값, 가위표 아이콘 추가로 각각 고쳤습니다. 측정과 눈검수가 보는 건 루프 상태뿐이라는 걸 몸으로 배웠습니다.
마지막으로 06 시트의 뒷면이었습니다. 처음엔 시트가 올라오는 동안 linear 페이드로 어두워지게 했더니 반쯤 올라온 시트가 잘린 장이 대표 이미지로 뽑혔습니다. 뒷면이 시트가 선 뒤 한 프레임에 steps(1, end) 로 바뀌게 하니 카드 전체가 한 번에 바뀌는 그 장이 최대 변화가 되어, 칸 넷이 다 선 시트가 대표가 됐습니다. 숫자가 목표에 닿았다가 튀는 움직임에 무게를 실어 주고 싶다면 물리 시뮬레이션이 스프링과 관성으로 같은 문제를 풉니다.
접근성
색 배합은 감으로 고르지 않았습니다 — WCAG 2.1 의 상대 휘도 식으로 조합마다 직접 계산했고, 쓰지 않기로 한 짝은 맨 아래 두 줄뿐입니다.
| 앞 | 뒤 | 실측 대비 | 본문 기준 |
|---|---|---|---|
| 잉크 #15131a | 흰 #ffffff | 18.42:1 | 통과 |
| 잉크 #15131a | 크림 #fdf8f0 | 17.42:1 | 통과 |
| 잉크 #15131a | 하늘 #7fd4f5 | 11.09:1 | 통과 |
| 슬레이트 #5b5566 | 흰 #ffffff | 7.15:1 | 통과 |
| 슬레이트 #5b5566 | 크림 #fdf8f0 | 6.76:1 | 통과 |
| 흰 #ffffff | 잉크 #15131a | 18.42:1 | 통과 |
| 미스트 #bdb7c6 | 잉크 #15131a | 9.43:1 | 통과 |
| 흰 #ffffff | 딥블루 #0f5f8a | 6.94:1 | 통과 |
| 민트 #78e0b4 | 잉크 #15131a | 11.51:1 | 통과 |
| 살구 #ffb3a0 | 잉크 #15131a | 10.71:1 | 통과 |
| 잉크 #15131a | 주황 #ff4d1f | 5.56:1 | 통과 |
| 흰 #ffffff | 주황 #ff4d1f | 3.32:1 | 미달 — 쓰지 않는다 |
| 흰 #ffffff | 노랑 #ffd23f | 1.44:1 | 미달 — 쓰지 않는다 |
이렇게 통과한 짝만 골라 다시 렌더한 아홉 벌이 zip 에 그대로 들어 있고, 여는 비밀번호는 xbet8bb8 입니다. 움직임을 덜어 달라는 설정(prefers-reduced-motion: reduce)에서는 아홉 개 모두 움직임만 끄고 상태와 정보는 남겨 둡니다. 01 은 호버·초점의 링이 그대로 보이고 aria-pressed 가 고른 버튼을 알립니다. 02 는 라벨이 접힌 채 멈추되 호버·초점의 펼침은 살아 있습니다. 03 은 복사 결과를 토스트로 그대로 알립니다. 04 는 숫자가 선언된 최종값으로 바로 찹니다. 05 와 07 은 접힌 상태로 멈추되 여닫기 자체는 즉시 반응합니다. 06 은 시트가 연 채 서고 초점 가두기는 그대로 작동합니다. 08 은 코드가 서 있고 스캔 선만 사라집니다. 09 는 고른 탭과 그 판이 그대로 보입니다.
화면 구조 쪽에 실제로 들어간 장치는 이렇습니다. 아이콘 버튼은 전부 접근성 이름을 갖고 있고(라벨이 보이면 텍스트로, 아이콘만 있으면 aria-label 로), 01 의 토글은 aria-pressed, 05·07 의 여닫기는 aria-expanded 와 aria-controls 를 씁니다. 06 시트는 role="dialog" 와 aria-modal, 초점 가두기, Esc 닫기를 실제로 구현했고 08 팝업도 같은 역할 이름을 씁니다. 04 의 배지는 장식이라 aria-hidden 이고 숫자는 버튼의 접근성 이름에 말로 들어 있습니다. 09 는 role="tablist" 와 로빙 탭인덱스로 키보드만으로 탭을 오가며, 코드칸은 읽기 전용임을 알립니다. 03 의 결과 문장은 role="status" 와 aria-live="polite" 로 화면을 못 보는 사람에게 같은 자리에서 한 번만 읽힙니다. 복사·여닫기 근거는 MDN 의 Clipboard API 문서와 aria-dialog 안내, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 글 읽는 도중의 알림이 궁금하면 토스트 쌓기를, 화면을 아래에서 조작하는 바가 필요하면 플로팅 독 내비게이션으로 이어집니다. 이 사이트에서 코드를 어떻게 만들고 확인하는지는 소개에 남겨 두었습니다.
FAQ
sns 공유 버튼은 몇 개까지 두는 게 좋나요?
셋에서 넷입니다. 01 의 링크·메일·메시지·QR 처럼 하는 일이 다른 넷이면 충분하고, 플랫폼 로고를 여덟 개 늘어놓으면 오히려 하나도 안 눌립니다. 자리가 좁으면 05 처럼 버튼 하나로 접었다 펼치는 편이 낫습니다. 플랫폼 이름과 로고를 그대로 가져오는 것도 피하세요 — 상표와 브랜드 색을 무단으로 쓰는 셈이 됩니다. 08 의 QR 팝업처럼 접혔다 뜨는 그릇 자체의 경첩은 모달 팝업 애니메이션이 따로 다룹니다.
복사가 되었다고 어떻게 확신하나요?
브라우저에게 물어야 합니다. navigator.clipboard.writeText 는 약속을 돌려주므로 03 처럼 성공·실패를 각각 받아 결과를 문장으로 알리게 하고, 실패에는 가위표를 보여 주세요. 시크릿 모드나 권한 설정에서 클립보드는 조용히 막히는 일이 있어서, 성공만 가정한 안내는 거짓말이 됩니다. API 자체가 없는 오래된 브라우저용 대비책도 함께 두면 됩니다.
QR 은 이미지로 넣으면 안 되나요?
넣을 수 있지만 이 모음은 그러지 않았습니다. 08 처럼 모듈 행렬을 그리드로 그리면 확대해도 뭉개지지 않고, 색을 덧입히는 실수가 없으며, 파일 하나를 더 들고 다니지 않아도 됩니다. 어떤 방식이든 지켜야 할 규칙은 같습니다 — 검은 칸과 흰 칸의 대비를 유지하고, 위에 다른 요소를 얹지 않고, 코드가 무엇을 담았는지 aria-label 로 알려 주는 것입니다 — 08 은 「주소를 담은 QR 코드」 라고 이름 붙였습니다.









