シェアボタン デザイン パターン9選 — コピペで動く
シェアボタン デザインは、訪問者がワンタップで記事を外へ送り出す最後の扉の形そのものです。ここに集めた9つは見かけだけの作り物ではなく、押すと本当にコピーされるシェアUI を HTML・CSS・JavaScript で組んだものなので、そのまま貼ればすぐ動きます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 一行シェアバー
- 02 記事横の縦シェアバー
- 03 リンクコピートースト
- 04 シェア数カウントアップ
- 05 展開するアイコンシェアボタン
- 06 モバイルシェアシート
- 07 下部固定シェアバー
- 08 QRコード共有ポップアップ
- 09 メール・埋め込み共有ボックス
9つは フローティング ボタン デザイン の流行の順ではなく、訪問者がシェアに出会う順に並べました。読み終えた場所で出会う一行シェアバー(01)から、読んでいる間に横に付く縦バー(02)、押した瞬間に結果を伝えるコピートースト(03)へと続きます。信頼を見せるシェア数カウントアップ(04)を過ぎると、場所を節約する二つの方法、ボタン1つから展開するアイコン(05)と下部固定バー(07)が現れ、スマホでは下から上がってくるシェアシート(06)がその役を担います。最後の2つで共有を閉じます。オフラインをつなぐQRポップアップ(08)と、受け取り手を直接選ぶメール・埋め込みボックス(09)です。9つはインク #15131a・クリーム #fdf8f0・空色 #7fd4f5 という同じ色の値を共有するので、2つ3つを混ぜて使っても色がずれません。共有が済んだあと読み手を元の場所へ戻す役はパンくずリストが、画面から手を離さずに済ませる軸はショートカットキー 表示 UIが引き受けます。
01一行シェアバー
sns シェアボタン デザイン で何が見えるか
カードの一番下に、ラベルと44pxの丸いアイコンボタン4つが一行で並ぶ、誰もが最初に作る形です。ホバー・フォーカスでボタンが4px浮き上がり、重ねて置いた円が 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
押したボタンは 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記事横の縦シェアバー
画面で起きること
マガジン型の長い記事では、シェアバーは本文の横に縦1列で並び、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
切り取っておく幅には条件があり、残りがラベルの始まる位置(余白12px+アイコン20px+その間の隙間8pxの合計40px)を超えると、折りたたんだ状態でラベルの頭文字が透けて見えます。
/* 展開した幅(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 がない・書き込みが遮られた場合を見過ごさないこと。このデモは 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
結果の文は 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から目標まで上げるのにJavaScriptのカウンターは要りません。@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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
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展開するアイコンシェアボタン
画面で起きること
ヘッダーが狭いワンページには、シェアボタンを1つだけ置きます。押すとプラットフォームのボタン4つが時間差で横に展開します。折りたたみは幅を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">狭い場所ではボタンを1つだけ置きます</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
畳んでいる間、ボタンは 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" のシートが上がり、背面が暗くなり、4つの枠はシートより遅れて浮かび上がります。アクセシビリティの骨格はフォーカストラップです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
開くと最初のボタンへフォーカスが移り、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以上上へスワイプすると、アイコン5つが展開します。展開の高さは固定pxではなく 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
内側のラッパーには 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
上をなぞるスキャン線はプレビューにだけ現れます。
// 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: に件名と本文が URL エンコード済みのまま込められていて、押すとほとんど書き終えた手紙が開き、埋め込みタブは読み取り専用のコード欄とコピーボタンを渡します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
タブの切り替えはクリックと左右矢印ででき、下線はマス幅の単位(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; // ロービングtabIndex
panes[k].hidden = k !== at;
});
root.style.setProperty('--n', at);
}
どこで壊れるか — 落とし穴
最初に壊れたのは代表画像でした。04カウントアップでは、数字が目標(128・64・39)に届く瞬間とバッジが跳ねる瞬間を、同じサンプル枠に入れてはいけません。24フレームは2秒を4.1667%間隔で切るので、2つの出来事が1枠に集中すると「数え途中の数字」を写した場面の変化量が最も大きくなり、それが代表に選ばれるためです。現在のソースは数字が50%で目標に届き58%で跳ねるよう2つの出来事を8%間隔・サンプル2枠に分けて、値が満ちた後のバッジの跳ねが代表画像になるようにしています。
2番目は02縦バーのマス幅でした。バーが入るグリッドのマスを折りたたんだ幅44pxで取ると、展開した瞬間ラベルが本文の見出しの上に重なりました。展開した幅全体をマスとして先に確保すべきでした。同じファイルで、折りたたんだ状態の切り取り幅も92pxに取っていたせいでラベルの頭文字の左4pxが透け、残りがラベルの始まる40px(余白12px+アイコン20px+隙間8px)に収まる100pxへ広げて解決しました。
3番目は静的な状態でした。.is-demo のループでは問題ない9つのうち3つで、ループを外すと画面が空になるか重なりました。09はパネルに display: flex をクラスで与えたため [hidden] の既定 display: none に勝って2枚が同時に見え、08は手で見る時もスキャン線が残り、03は失敗の案内にアイコンがありませんでした。.ee__panel[hidden] { display: none } の再宣言、スキャン線の opacity: 0 初期値、バツ印アイコンの追加でそれぞれ直しました。計測も目視も、見るのはループの状態だけだということを身をもって学びました。
最後は06シートの背面でした。最初はシートが上がる間に linear フェードで暗くするようにしたところ、半分上がったシートが途中で切れて見える場面が代表画像に選ばれました。背面の暗さが、シートが着いた後の1フレームで steps(1, end) に切り替わるようにすると、カード全体が一度に変わるその場面が最大の変化になり、4つの枠が全部立ったシートが代表になりました。数字が目標に届いて跳ねる動きに重みを乗せたいときは、物理シミュレーションがバネと慣性で同じ問題を解いています。
アクセシビリティ
色の組み合わせは感で選ばず、WCAG 2.1 の相対輝度の式で組み合わせごとに直接計算しました。使わないと決めた組み合わせは、表の一番下の2行だけです。
| 前景 | 背景 | 実測コントラスト | 本文の基準 |
|---|---|---|---|
| インク #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 | 不合格 — 使わない |
こうして合格した組み合わせだけで描き直した9式が zip にそのまま入っており、開くパスワードは xbet8bb8 です。動きを減らしてほしいという設定(prefers-reduced-motion: reduce)では、9つすべてが動きだけを消し、状態と情報を残します。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" とロービングtabIndex でキーボードだけでタブを巡れ、コード欄は読み取り専用であることを伝えます。03の結果の文は role="status" と aria-live="polite" の一枠で、画面の見えない人に同じ場所で一度だけ読まれます。コピーとダイアログのよりどころは MDN の Clipboard API 文書とARIAダイアログのパターン、コントラストの基準は W3C の最小コントラスト解説に従いました。読んでいる途中の通知が気になる方はトーストの重ね方を、画面の下から操作するバーが必要ならフローティングドックナビゲーションをお読みください。このサイトがコードをどう作り、確認しているかは紹介ページに書いてあります。
FAQ
x シェアボタン デザイン も含めて、シェアボタンは何個まで置くのがいいですか?
3つか4つです。01のようにリンク・メール・メッセージ・QR と役割の違う4つなら十分で、プラットフォームのロゴを8つ並べると逆にどれも押されません。場所が狭ければ、05のようにボタン1つに畳む方がよいです。プラットフォームの名前とロゴをそのまま持ち込むのも避けてください。商標とブランド色を無断で使うことになります。08のQRポップアップのように折りたたんで浮かぶ器の蝶番は、モーダル アニメーションが開閉の軸を別に扱っています。
コピーが成功したことをどうやって確かめますか?
ブラウザに尋ねるしかありません。navigator.clipboard.writeText はプロミスを返すので、03のように成功と失敗をそれぞれ受け取り、結果を言葉で伝え、失敗にはバツ印を出してください。クリップボードはシークレットモードや権限の設定で静かに遮られることがあり、成功だけを前提にした案内は嘘になります。API そのものがない古いブラウザへの備えも、もう一段の分岐で足ります。
QR は画像で入れてはいけないのですか?
入れられますが、このシリーズではそうしませんでした。08のようにモジュール行列をグリッドで描くと、拡大してもにじまず、色を重ねる間違いが起きず、追加のファイルを持たずに済みます。どの作り方でも守るべきルールは同じです。黒と白のコントラストを保ち、上に他の要素を重ねず、コードが何を収めているかを aria-label で伝えることです。08は「アドレスを収めたQRコード」と名付けています。









