css ボタン 押したら変わる 9選 — 状態遷移の実装
css ボタン 押したら変わる、つまり押した瞬間から結果が出るまでボタン自身が姿を変えて伝える作りを9つ集めました。押したあとが本番です。色だけ変える見せかけではなく、実際に固まり、回り、戻ります。
01 btn-state-async-cycle
02 btn-state-enable-gate
03 btn-state-toggle-pressed
04 btn-state-like-count-roll
05 btn-state-download-progress
06 btn-state-hold-delete
07 btn-state-split-menu
08 btn-state-segment-radio
09 btn-state-copy-feedback
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 読み込みから完了まで一巡
- 02 条件が揃うと開くボタン
- 03 オンとオフを一つのボタンが持つ
- 04 数字が転がるいいねボタン
- 05 ボタン自身が進捗バーになる
- 06 長押しで初めて消える削除
- 07 主な操作と別の道が並ぶボタン
- 08 選んだ区画へ滑る錠剤
- 09 コピーしたと一瞬だけ言うボタン
並び順は人気順ではなく、ボタン一つが経験する順番です。まず押されて答えを待ち、元へ戻ります(01)。次に、まだ押せない理由を示し(02)、押されたまま留まることを覚えます(03)。真ん中の三つは、押した結果が画面に残る場合です。数字として残り(04)、数秒の進捗として残り(05)、取り消せないからもう一度確かめます(06)。最後の三つは、ボタンが一つきりではないときです。隣に別の道が並び(07)、互いに排他的な三つの区画の一つになり(08)、押したという事実を一瞬だけ伝えて黙ります(09)。九つに共通する原則は一つです。いまの状態はクラスではなく aria-busy・aria-pressed・aria-disabled・aria-checked・aria-expanded のうち場面に合う属性一か所だけに書き、見た目はその属性を読んで決めます。二か所に書けば必ずずれますし、ずれるのはいつも目に見えない側です。css ボタン ホバー だけでは指が触れているかどうかしか伝わらず、押したあとに何が起きたかは別の状態として持つ必要があります。押す前の装飾を選ぶ仕事はcss ボタン ホバー アニメーションが担い、アイコンだけ残した版はフローティング ボタン アイコンへ分かれます。状態が分かれるコマ数は三枚を超えないようにしました。01番の読み込みと完了のあいだに飛ばす枠が増えると、押した人は結果ではなく途中を覚えることになるため、失敗の版はアイコンの色だけを変えてコマ数はそのままにしています。トグルの03番は押すたびに前の値を控えておき、押し間違いだと感じたときにすぐ戻れる隙間を残しています。長押しで進む4番目以降は確定を遅らせるのではなく色を薄めるだけにしました。
01読み込みから完了まで一巡
何が見えるか
一度押すとラベルがスピナー付きの「保存中」に変わり、1.2秒後にチェックへ切り替わり、その2秒後に元のラベルへ戻ります。回っている間は disabled なので二度押せず、aria-busy が処理中であることを伝えます。

<main class="stage">
<div class="ac is-demo">
<button class="ac__btn" id="ac-btn" type="button" data-state="idle" aria-busy="false">
<span class="ac__face" data-face="idle">
<span class="ac__t">保存</span>
</span>
<span class="ac__face" data-face="busy">
<span class="ac__ic ac__ic--spin" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M136 32v32a8 8 0 0 1-16 0V32a8 8 0 0 1 16 0m88 88h-32a8 8 0 0 0 0 16h32a8 8 0 0 0 0-16m-45.09 47.6a8 8 0 0 0-11.31 11.31l22.62 22.63a8 8 0 0 0 11.32-11.32ZM128 184a8 8 0 0 0-8 8v32a8 8 0 0 0 16 0v-32a8 8 0 0 0-8-8m-50.91-16.4l-22.63 22.62a8 8 0 0 0 11.32 11.32l22.62-22.63a8 8 0 0 0-11.31-11.31M72 128a8 8 0 0 0-8-8H32a8 8 0 0 0 0 16h32a8 8 0 0 0 8-8m-6.22-73.54a8 8 0 0 0-11.32 11.32L77.09 88.4A8 8 0 0 0 88.4 77.09Z"/></g></svg></span>
<span class="ac__t">保存中</span>
</span>
<span class="ac__face" data-face="done">
<span class="ac__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="ac__t">保存しました</span>
</span>
</button>
<p class="ac__log" role="status" aria-live="polite">
<span class="ac__word" data-word="idle">押せます</span>
<span class="ac__word" data-word="busy">送信中です</span>
<span class="ac__word" data-word="done">終わりました</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.ac {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ac__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
box-shadow: $shadow-raised;
}
.ac__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.ac__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.ac__ic { display: inline-flex; flex: 0 0 auto; }
.ac__ic svg { display: block; }
.ac__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 사람이 실제로 누른 경로 — 루프를 껐을 때도 화면이 비지 않게 정적 규칙을 먼저 둔다
.ac:not(.is-demo) .ac__btn[data-state="idle"] .ac__face[data-face="idle"],
.ac:not(.is-demo) .ac__btn[data-state="busy"] .ac__face[data-face="busy"],
.ac:not(.is-demo) .ac__btn[data-state="done"] .ac__face[data-face="done"] { opacity: 1; }
.ac:not(.is-demo) .ac__btn[data-state="busy"] { background-color: $subject-blue; color: $subject-cream; }
.ac:not(.is-demo) .ac__btn[data-state="done"] { background-color: $color; color: $subject-ink; }
.ac__log {
position: relative;
margin: 0;
width: 100%;
height: 18px;
}
.ac__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
opacity: 0;
}
.ac:not(.is-demo) .ac__btn[data-state="idle"] ~ .ac__log .ac__word[data-word="idle"],
.ac:not(.is-demo) .ac__btn[data-state="busy"] ~ .ac__log .ac__word[data-word="busy"],
.ac:not(.is-demo) .ac__btn[data-state="done"] ~ .ac__log .ac__word[data-word="done"] { opacity: 1; }
// 스피너는 도는 동안 매 프레임 달라진다 — 컷 두 번만으로는 움직인 프레임이 안 찬다
.ac__ic--spin svg {
animation-name: ac-spin;
animation-duration: 800ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ac-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// ── 자동 루프: idle → busy(20%) → done(60%) → idle(88%). 상태 교체는 한 프레임에 끊는다 ──
.ac.is-demo .ac__face,
.ac.is-demo .ac__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__face[data-face="idle"],
.ac.is-demo .ac__word[data-word="idle"] { animation-name: ac-idle; }
.ac.is-demo .ac__face[data-face="busy"],
.ac.is-demo .ac__word[data-word="busy"] { animation-name: ac-busy; }
.ac.is-demo .ac__face[data-face="done"],
.ac.is-demo .ac__word[data-word="done"] { animation-name: ac-done; }
@keyframes ac-idle {
0% { opacity: 1; }
20% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ac-busy {
0% { opacity: 0; }
20% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ac-done {
0% { opacity: 0; }
60% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 껍질 색은 컷으로, 눌림 반동은 부드럽게 — 두 애니메이션을 한 요소에 겹쳐 건다
.ac.is-demo .ac__btn {
animation-name: ac-skin, ac-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes ac-skin {
0% { background-color: $subject-cream; color: $subject-ink; }
20% { background-color: $subject-blue; color: $subject-cream; }
60% { background-color: $color; color: $subject-ink; }
88% { background-color: $subject-cream; color: $subject-ink; }
100% { background-color: $subject-cream; color: $subject-ink; }
}
// 완료로 갈리는 순간 버튼이 한 번 눌렸다 펴진다 — 컷만으로는 대표 장면이 밋밋하다
@keyframes ac-pop {
0% { transform: scale(1); }
18% { transform: scale(1); }
22% { transform: scale(.96); }
30% { transform: scale(1); }
58% { transform: scale(1); }
63% { transform: scale(1.04); }
72% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.ac { gap: $sp-2; }
.ac__btn { height: 44px; }
.ac__t { font-size: 13px; line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__face,
.ac.is-demo .ac__word,
.ac.is-demo .ac__btn,
.ac__ic--spin svg { animation: none !important; }
.ac.is-demo .ac__face[data-face="idle"],
.ac.is-demo .ac__word[data-word="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ac__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ac__btn:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 3px;
}
.ac__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.ac__ic {
display: inline-flex;
flex: 0 0 auto;
}
.ac__ic svg {
display: block;
}
.ac__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.ac:not(.is-demo) .ac__btn[data-state=idle] .ac__face[data-face=idle],
.ac:not(.is-demo) .ac__btn[data-state=busy] .ac__face[data-face=busy],
.ac:not(.is-demo) .ac__btn[data-state=done] .ac__face[data-face=done] {
opacity: 1;
}
.ac:not(.is-demo) .ac__btn[data-state=busy] {
background-color: #2f6df6;
color: #fff7e6;
}
.ac:not(.is-demo) .ac__btn[data-state=done] {
background-color: #4cd4a6;
color: #17141a;
}
.ac__log {
position: relative;
margin: 0;
width: 100%;
height: 18px;
}
.ac__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
opacity: 0;
}
.ac:not(.is-demo) .ac__btn[data-state=idle] ~ .ac__log .ac__word[data-word=idle],
.ac:not(.is-demo) .ac__btn[data-state=busy] ~ .ac__log .ac__word[data-word=busy],
.ac:not(.is-demo) .ac__btn[data-state=done] ~ .ac__log .ac__word[data-word=done] {
opacity: 1;
}
.ac__ic--spin svg {
animation-name: ac-spin;
animation-duration: 800ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ac-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ac.is-demo .ac__face,
.ac.is-demo .ac__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__face[data-face=idle],
.ac.is-demo .ac__word[data-word=idle] {
animation-name: ac-idle;
}
.ac.is-demo .ac__face[data-face=busy],
.ac.is-demo .ac__word[data-word=busy] {
animation-name: ac-busy;
}
.ac.is-demo .ac__face[data-face=done],
.ac.is-demo .ac__word[data-word=done] {
animation-name: ac-done;
}
@keyframes ac-idle {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ac-busy {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-done {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ac.is-demo .ac__btn {
animation-name: ac-skin, ac-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes ac-skin {
0% {
background-color: #fff7e6;
color: #17141a;
}
20% {
background-color: #2f6df6;
color: #fff7e6;
}
60% {
background-color: #4cd4a6;
color: #17141a;
}
88% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes ac-pop {
0% {
transform: scale(1);
}
18% {
transform: scale(1);
}
22% {
transform: scale(0.96);
}
30% {
transform: scale(1);
}
58% {
transform: scale(1);
}
63% {
transform: scale(1.04);
}
72% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.ac {
gap: 8px;
}
.ac__btn {
height: 44px;
}
.ac__t {
font-size: 13px;
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__face,
.ac.is-demo .ac__word,
.ac.is-demo .ac__btn,
.ac__ic--spin svg {
animation: none !important;
}
.ac.is-demo .ac__face[data-face=idle],
.ac.is-demo .ac__word[data-word=idle] {
opacity: 1;
}
}// 실제 동작: 한 번 누르면 busy → done → idle 로 스스로 돌아온다. 도는 동안 버튼은 잠긴다
var root = document.querySelector('.ac');
var btn = document.getElementById('ac-btn');
var t1 = 0, t2 = 0;
function setState(s) {
btn.setAttribute('data-state', s);
btn.setAttribute('aria-busy', String(s === 'busy'));
btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimeout(t1);
clearTimeout(t2);
setState('busy');
t1 = setTimeout(function () { setState('done'); }, 1200); // 서버가 답한 시점
t2 = setTimeout(function () { setState('idle'); }, 3200); // 완료 표시를 2초 보여 주고 원복
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./AsyncCycleButton.module.scss";
export type AsyncState = "idle" | "busy" | "done";
export type AsyncCycleButtonProps = {
labels?: Record<AsyncState, string>;
logs?: Record<AsyncState, string>;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Timer = ReturnType<typeof setTimeout>;
const BUSY_MS = 1200; // 서버가 답한 시점
const DONE_MS = 2000; // 완료 표시를 2초 보여 주고 원복
const SPINNER = (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M136 32v32a8 8 0 0 1-16 0V32a8 8 0 0 1 16 0m88 88h-32a8 8 0 0 0 0 16h32a8 8 0 0 0 0-16m-45.09 47.6a8 8 0 0 0-11.31 11.31l22.62 22.63a8 8 0 0 0 11.32-11.32ZM128 184a8 8 0 0 0-8 8v32a8 8 0 0 0 16 0v-32a8 8 0 0 0-8-8m-50.91-16.4l-22.63 22.62a8 8 0 0 0 11.32 11.32l22.62-22.63a8 8 0 0 0-11.31-11.31M72 128a8 8 0 0 0-8-8H32a8 8 0 0 0 0 16h32a8 8 0 0 0 8-8m-6.22-73.54a8 8 0 0 0-11.32 11.32L77.09 88.4A8 8 0 0 0 88.4 77.09Z" />
</g>
);
const CHECK = (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
);
export default function AsyncCycleButton({
labels = { idle: "Save", busy: "Saving", done: "Saved" },
logs = { idle: "Ready to press", busy: "Sending now", done: "All done" },
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: AsyncCycleButtonProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<AsyncState>("idle");
const t1 = useRef<Timer>(null);
const t2 = useRef<Timer>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function clear() {
if (t1.current !== null) clearTimeout(t1.current);
if (t2.current !== null) clearTimeout(t2.current);
}
useEffect(() => clear, []); // 떠날 때 예약된 타이머를 버린다
// 한 번 누르면 busy → done → idle 로 스스로 돌아온다. 도는 동안 버튼은 잠긴다
function run() {
setDemo(false); // 사람이 눌렀으니 자동 루프를 끈다
clear();
setState("busy");
t1.current = setTimeout(() => setState("done"), BUSY_MS);
t2.current = setTimeout(() => setState("idle"), BUSY_MS + DONE_MS);
}
return (
<div className={`${styles.ac} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={styles.btn}
type="button"
data-state={state}
aria-busy={state === "busy"}
disabled={state !== "idle"}
onClick={run}
>
<span className={styles.face} data-face="idle">
<span className={styles.txt}>{labels.idle}</span>
</span>
<span className={styles.face} data-face="busy">
<span className={`${styles.ic} ${styles.icSpin}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{SPINNER}</svg>
</span>
<span className={styles.txt}>{labels.busy}</span>
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{CHECK}</svg>
</span>
<span className={styles.txt}>{labels.done}</span>
</span>
</button>
<p className={styles.log} role="status" aria-live="polite">
<span className={styles.word} data-word="idle">{logs.idle}</span>
<span className={styles.word} data-word="busy">{logs.busy}</span>
<span className={styles.word} data-word="done">{logs.done}</span>
</p>
</div>
);
}01 · 読み込みから完了まで一巡 — 計測 累積面積 10.988% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
状態名を変える関数が三つの属性をまとめて更新するので、見た目と支援技術が食い違う隙がありません。
function setState(s) {
btn.setAttribute('data-state', s);
btn.setAttribute('aria-busy', String(s === 'busy'));
btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimeout(t1);
clearTimeout(t2);
setState('busy');
t1 = setTimeout(function () { setState('done'); }, 1200);
t2 = setTimeout(function () { setState('idle'); }, 3200);
});
02条件が揃うと開くボタン
css ボタン 無効 の状態は画面でどう見えるか
入力欄がメールアドレスの形式になるまでボタンは薄く固まっていますが、disabled ではなく aria-disabled を使うのでフォーカスは受け取ります。固まったまま押しても何も起きず、代わりに理由が吹き出しで1.4秒だけ表示されます。

<main class="stage">
<div class="eg is-demo">
<div class="eg__field">
<input class="eg__input" id="eg-mail" type="email" required placeholder="[email protected]" aria-label="メールアドレス">
<span class="eg__ghost" aria-hidden="true">[email protected]</span>
</div>
<div class="eg__wrap">
<button class="eg__btn" id="eg-btn" type="button" aria-disabled="true" aria-describedby="eg-why">登録する</button>
<p class="eg__tip" id="eg-why" role="note">メール形式が正しいと押せます</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.eg {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.eg__field {
position: relative;
height: 40px;
}
.eg__input {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
width: 100%;
height: 100%;
border: 2px solid $color;
border-radius: $r-sm;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.eg__input:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 루프에서만 쓰는 가짜 입력 글자 — 진짜 입력칸 위에 겹쳐 한 글자씩 드러난다
.eg__ghost {
position: absolute;
left: 14px;
top: 10px;
overflow: hidden;
white-space: nowrap;
width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: $color;
}
.eg:not(.is-demo) .eg__ghost { display: none; }
.eg.is-demo .eg__input::placeholder { color: transparent; }
.eg__wrap {
position: relative;
padding-bottom: $sp-8; // 절대 배치한 말풍선 높이만큼 자리를 미리 비워 둔다 (scrollHeight 넘침 방지)
}
.eg__btn {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-4;
width: 100%;
height: 44px;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: 15px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.eg__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.eg__btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
.eg__btn[aria-disabled="false"] { background-color: $color; color: $stage-yellow; box-shadow: $shadow-raised; }
.eg__btn[data-sent="1"] { background-color: $subject-mint; color: $color; }
.eg__tip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background-color: $color;
color: $subject-cream;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 500;
pointer-events: none; // 말풍선이 버튼의 호버·클릭을 가로채면 안 된다
opacity: 0;
}
.eg:not(.is-demo) .eg__btn[aria-disabled="true"]:hover + .eg__tip,
.eg:not(.is-demo) .eg__btn:focus-visible + .eg__tip,
.eg:not(.is-demo) .eg__btn[data-why="1"] + .eg__tip { opacity: 1; }
// ── 자동 루프: 잠김 → 한 글자씩 입력(20~46%) → 열림(50%) → 다시 잠김(86%) ──
.eg.is-demo .eg__ghost {
animation-name: eg-type;
animation-duration: $dur-loop;
animation-timing-function: steps(8, end);
animation-iteration-count: infinite;
}
@keyframes eg-type {
0% { width: 0; }
20% { width: 0; }
46% { width: 120px; }
86% { width: 120px; }
87% { width: 0; }
100% { width: 0; }
}
.eg.is-demo .eg__btn {
animation-name: eg-open;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-open {
0% { opacity: .45; background-color: $subject-cream; color: $color; }
50% { opacity: 1; background-color: $color; color: $stage-yellow; }
86% { opacity: .45; background-color: $subject-cream; color: $color; }
100% { opacity: .45; background-color: $subject-cream; color: $color; }
}
.eg.is-demo .eg__tip {
animation-name: eg-tip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-tip {
0% { opacity: 1; }
50% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
// 열리는 순간 버튼이 한 번 솟는다 — 컷 두 번만으로는 움직인 프레임이 모자란다
.eg.is-demo .eg__wrap {
animation-name: eg-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes eg-rise {
0% { transform: translateY(0); }
48% { transform: translateY(0); }
54% { transform: translateY(-6px); }
66% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.eg { gap: $sp-2; }
.eg__field { height: 36px; }
.eg__btn { height: 40px; font-size: 13px; line-height: 18px; }
.eg__ghost { top: 8px; }
.eg__wrap { padding-bottom: $sp-6; }
}
@media (prefers-reduced-motion: reduce) {
.eg.is-demo .eg__ghost,
.eg.is-demo .eg__btn,
.eg.is-demo .eg__tip,
.eg.is-demo .eg__wrap { animation: none !important; }
.eg.is-demo .eg__tip { opacity: 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);
}
.eg {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.eg__field {
position: relative;
height: 40px;
}
.eg__input {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
width: 100%;
height: 100%;
border: 2px solid #17141a;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.eg__input:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.eg__ghost {
position: absolute;
left: 14px;
top: 10px;
overflow: hidden;
white-space: nowrap;
width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: #17141a;
}
.eg:not(.is-demo) .eg__ghost {
display: none;
}
.eg.is-demo .eg__input::placeholder {
color: transparent;
}
.eg__wrap {
position: relative;
padding-bottom: 32px;
}
.eg__btn {
box-sizing: border-box;
margin: 0;
padding: 0 16px;
width: 100%;
height: 44px;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 15px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.eg__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.eg__btn[aria-disabled=true] {
opacity: 0.45;
cursor: not-allowed;
}
.eg__btn[aria-disabled=false] {
background-color: #17141a;
color: #ffd23f;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.eg__btn[data-sent="1"] {
background-color: #4cd4a6;
color: #17141a;
}
.eg__tip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 4px 8px;
border-radius: 4px;
background-color: #17141a;
color: #fff7e6;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 500;
pointer-events: none;
opacity: 0;
}
.eg:not(.is-demo) .eg__btn[aria-disabled=true]:hover + .eg__tip,
.eg:not(.is-demo) .eg__btn:focus-visible + .eg__tip,
.eg:not(.is-demo) .eg__btn[data-why="1"] + .eg__tip {
opacity: 1;
}
.eg.is-demo .eg__ghost {
animation-name: eg-type;
animation-duration: 2s;
animation-timing-function: steps(8, end);
animation-iteration-count: infinite;
}
@keyframes eg-type {
0% {
width: 0;
}
20% {
width: 0;
}
46% {
width: 120px;
}
86% {
width: 120px;
}
87% {
width: 0;
}
100% {
width: 0;
}
}
.eg.is-demo .eg__btn {
animation-name: eg-open;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-open {
0% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
50% {
opacity: 1;
background-color: #17141a;
color: #ffd23f;
}
86% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
100% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
}
.eg.is-demo .eg__tip {
animation-name: eg-tip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-tip {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.eg.is-demo .eg__wrap {
animation-name: eg-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes eg-rise {
0% {
transform: translateY(0);
}
48% {
transform: translateY(0);
}
54% {
transform: translateY(-6px);
}
66% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.eg {
gap: 8px;
}
.eg__field {
height: 36px;
}
.eg__btn {
height: 40px;
font-size: 13px;
line-height: 18px;
}
.eg__ghost {
top: 8px;
}
.eg__wrap {
padding-bottom: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.eg.is-demo .eg__ghost,
.eg.is-demo .eg__btn,
.eg.is-demo .eg__tip,
.eg.is-demo .eg__wrap {
animation: none !important;
}
.eg.is-demo .eg__tip {
opacity: 1;
}
}// 실제 동작: disabled 대신 aria-disabled 를 쓴다. 초점은 받되 동작만 막아, 왜 못 누르는지 읽힐 수 있다
var root = document.querySelector('.eg');
var mail = document.getElementById('eg-mail');
var btn = document.getElementById('eg-btn');
function validate() {
var ok = mail.checkValidity() && mail.value.length > 0;
btn.setAttribute('aria-disabled', String(!ok));
return ok;
}
mail.addEventListener('input', function () {
root.classList.remove('is-demo');
validate();
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!validate()) {
btn.setAttribute('data-why', '1'); // 잠긴 이유를 한 번 흔들어 보여 준다
setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
return;
}
btn.setAttribute('data-sent', '1');
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./EnableGateButton.module.scss";
export type EnableGateButtonProps = {
labels?: { submit: string; tip: string; placeholder: string; mail: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Timer = ReturnType<typeof setTimeout>;
type MailEvt = { currentTarget: { value: string; checkValidity(): boolean } };
const TIP_ID = "eg-why";
const WHY_MS = 1400; // 잠긴 이유를 보여 주는 시간
export default function EnableGateButton({
labels = {
submit: "Sign up",
tip: "Enter a valid email first",
placeholder: "[email protected]",
mail: "Email address",
},
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: EnableGateButtonProps) {
const [demo, setDemo] = useState(true);
const [ok, setOk] = useState(false);
const [why, setWhy] = useState(false);
const [sent, setSent] = useState(false);
const tw = useRef<Timer>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => () => {
if (tw.current !== null) clearTimeout(tw.current); // 떠날 때 예약된 타이머를 버린다
}, []);
// disabled 대신 aria-disabled 를 쓴다. 초점은 받되 동작만 막아, 왜 못 누르는지 읽힐 수 있다
function check(e: MailEvt) {
setDemo(false); // 사람이 입력했으니 자동 루프를 끈다
const el = e.currentTarget;
setOk(el.checkValidity() && el.value.length > 0);
}
function press() {
setDemo(false);
if (!ok) {
setWhy(true); // 잠긴 이유를 한 번 흔들어 보여 준다
if (tw.current !== null) clearTimeout(tw.current);
tw.current = setTimeout(() => setWhy(false), WHY_MS);
return;
}
setSent(true);
}
return (
<div className={`${styles.eg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.field}>
<input
className={styles.input}
type="email"
required
placeholder={labels.placeholder}
aria-label={labels.mail}
onChange={check}
/>
<span className={styles.ghost} aria-hidden="true">{labels.placeholder}</span>
</div>
<div className={styles.wrap}>
<button
className={styles.btn}
type="button"
aria-disabled={!ok}
aria-describedby={TIP_ID}
data-why={why ? "1" : undefined}
data-sent={sent ? "1" : undefined}
onClick={press}
>
{labels.submit}
</button>
<p className={styles.tip} id={TIP_ID} role="note">{labels.tip}</p>
</div>
</div>
);
}02 · 条件が揃うと開くボタン — 計測 累積面積 15.819% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
本物の disabled はフォーカス自体を受け取れないので、その説明を読み上げる機会がそもそも訪れません。
function validate() {
var ok = mail.checkValidity() && mail.value.length > 0;
btn.setAttribute('aria-disabled', String(!ok));
return ok;
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!validate()) {
btn.setAttribute('data-why', '1');
setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
return;
}
btn.setAttribute('data-sent', '1');
});
03オンとオフを一つのボタンが持つ
どんな動きか
ボタンの中に二つの顔が同じ位置で重なっていて、どちらが見えるかは aria-pressed を読むセレクタだけが決めます。スクリプトがするのはその属性を反転させることだけなので、色もアイコンも読み上げられる状態もすべて同じ値から決まります。

<main class="stage">
<div class="tp is-demo">
<button class="tp__btn" id="tp-btn" type="button" aria-pressed="false">
<span class="tp__face" data-face="off">
<span class="tp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M53.92 34.62a8 8 0 1 0-11.84 10.76L58.82 63.8A79.6 79.6 0 0 0 48 104c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.8a40 40 0 0 0 78.4 0h15.44l19.44 21.38a8 8 0 1 0 11.84-10.76ZM128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a63.65 63.65 0 0 1 6.26-27.62L168.09 184Zm166-4.73a8.1 8.1 0 0 1-2.93.55a8 8 0 0 1-7.44-5.08C196.35 156.19 192 129.75 192 104a64 64 0 0 0-95.57-55.69a8 8 0 0 1-7.9-13.91A80 80 0 0 1 208 104c0 35.35 8.05 58.59 10.52 64.88a8 8 0 0 1-4.52 10.37Z"/></g></svg></span>
<span class="tp__lab">通知を受け取る</span>
</span>
<span class="tp__face" data-face="on">
<span class="tp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="tp__lab">通知オン</span>
</span>
</button>
<p class="tp__note">同じボタンが両方の状態を持ちます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.tp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-pill;
background-color: $subject-cream;
color: $color;
cursor: pointer;
box-shadow: $shadow-press;
}
.tp__btn:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
.tp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.tp__ic { display: inline-flex; flex: 0 0 auto; }
.tp__ic svg { display: block; }
.tp__lab {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 눌림 여부는 aria-pressed 한 곳에만 적힌다 — 색도 아이콘도 그 값에서 갈린다
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% { transform: scale(.94); }
60% { transform: scale(1.03); }
100% { transform: scale(1); }
}
.tp__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 1초마다 켜짐과 꺼짐을 한 프레임에 끊어 바꾼다 ──
.tp.is-demo .tp__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tp.is-demo .tp__face[data-face="off"] { animation-name: tp-off; }
.tp.is-demo .tp__face[data-face="on"] { animation-name: tp-on; }
@keyframes tp-off {
0% { opacity: 1; }
40% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes tp-on {
0% { opacity: 0; }
40% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
.tp.is-demo .tp__btn {
animation-name: tp-skin, tp-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes tp-skin {
0% { background-color: $subject-cream; color: $color; }
40% { background-color: $color; color: $stage-yellow; }
86% { background-color: $subject-cream; color: $color; }
100% { background-color: $subject-cream; color: $color; }
}
// 갈리는 순간마다 알약이 한 번 눌렸다 펴진다 — 컷 두 번만으로는 움직인 프레임이 안 찬다
@keyframes tp-pop {
0% { transform: scale(1); }
38% { transform: scale(1); }
43% { transform: scale(.94); }
52% { transform: scale(1.03); }
60% { transform: scale(1); }
84% { transform: scale(1); }
89% { transform: scale(.96); }
96% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.tp { gap: $sp-2; }
.tp__btn { height: 44px; }
.tp__lab { font-size: 13px; line-height: 18px; }
.tp__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__face,
.tp.is-demo .tp__btn,
.tp__btn.is-pop { animation: none !important; }
.tp.is-demo .tp__face[data-face="off"] { opacity: 1; }
}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);
}
.tp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 999px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.tp__btn:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.tp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.tp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.tp__ic svg {
display: block;
}
.tp__lab {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.tp:not(.is-demo) .tp__btn[aria-pressed=false] .tp__face[data-face=off],
.tp:not(.is-demo) .tp__btn[aria-pressed=true] .tp__face[data-face=on] {
opacity: 1;
}
.tp:not(.is-demo) .tp__btn[aria-pressed=true] {
background-color: #17141a;
color: #ffd23f;
}
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% {
transform: scale(0.94);
}
60% {
transform: scale(1.03);
}
100% {
transform: scale(1);
}
}
.tp__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.tp.is-demo .tp__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tp.is-demo .tp__face[data-face=off] {
animation-name: tp-off;
}
.tp.is-demo .tp__face[data-face=on] {
animation-name: tp-on;
}
@keyframes tp-off {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes tp-on {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.tp.is-demo .tp__btn {
animation-name: tp-skin, tp-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes tp-skin {
0% {
background-color: #fff7e6;
color: #17141a;
}
40% {
background-color: #17141a;
color: #ffd23f;
}
86% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes tp-pop {
0% {
transform: scale(1);
}
38% {
transform: scale(1);
}
43% {
transform: scale(0.94);
}
52% {
transform: scale(1.03);
}
60% {
transform: scale(1);
}
84% {
transform: scale(1);
}
89% {
transform: scale(0.96);
}
96% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.tp {
gap: 8px;
}
.tp__btn {
height: 44px;
}
.tp__lab {
font-size: 13px;
line-height: 18px;
}
.tp__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__face,
.tp.is-demo .tp__btn,
.tp__btn.is-pop {
animation: none !important;
}
.tp.is-demo .tp__face[data-face=off] {
opacity: 1;
}
}// 실제 동작: 클래스가 아니라 aria-pressed 를 뒤집는다. 보이는 색과 읽히는 상태가 한 값에서 나온다
var root = document.querySelector('.tp');
var btn = document.getElementById('tp-btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
btn.classList.remove('is-pop');
void btn.offsetWidth; // 같은 방향으로 다시 눌러도 반동이 한 번 더 돌게 리플로를 강제한다
btn.classList.add('is-pop');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TogglePressedButton.module.scss";
export type TogglePressedButtonProps = {
labels?: { off: string; on: string; note: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BELL_OFF = (
<g fill="currentColor">
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M53.92 34.62a8 8 0 1 0-11.84 10.76L58.82 63.8A79.6 79.6 0 0 0 48 104c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.8a40 40 0 0 0 78.4 0h15.44l19.44 21.38a8 8 0 1 0 11.84-10.76ZM128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a63.65 63.65 0 0 1 6.26-27.62L168.09 184Zm166-4.73a8.1 8.1 0 0 1-2.93.55a8 8 0 0 1-7.44-5.08C196.35 156.19 192 129.75 192 104a64 64 0 0 0-95.57-55.69a8 8 0 0 1-7.9-13.91A80 80 0 0 1 208 104c0 35.35 8.05 58.59 10.52 64.88a8 8 0 0 1-4.52 10.37Z" />
</g>
);
const BELL_ON = (
<g fill="currentColor">
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" />
</g>
);
export default function TogglePressedButton({
labels = { off: "Get alerts", on: "Alerts on", note: "One button holds both states" },
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: TogglePressedButtonProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [pop, setPop] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 클래스가 아니라 aria-pressed 를 뒤집는다. 보이는 색과 읽히는 상태가 한 값에서 나온다
function toggle() {
setDemo(false); // 사람이 눌렀으니 자동 루프를 끈다
setOn((v) => !v);
// 같은 방향으로 다시 눌러도 반동이 한 번 더 돌게 한 프레임 뒤에 다시 건다
setPop(false);
requestAnimationFrame(() => setPop(true));
}
return (
<div className={`${styles.tp} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={`${styles.btn} ${pop ? styles.isPop : ""}`}
type="button"
aria-pressed={on}
onClick={toggle}
onAnimationEnd={() => setPop(false)}
>
<span className={styles.face} data-face="off">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{BELL_OFF}</svg>
</span>
<span className={styles.lab}>{labels.off}</span>
</span>
<span className={styles.face} data-face="on">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{BELL_ON}</svg>
</span>
<span className={styles.lab}>{labels.on}</span>
</span>
</button>
<p className={styles.note}>{labels.note}</p>
</div>
);
}03 · オンとオフを一つのボタンが持つ — 計測 累積面積 10.316% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
同じ向きに続けて押してもはね返りが毎回走るように、クラスを外し、リフローを一度強制してから付け直します。
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% { transform: scale(.94); }
60% { transform: scale(1.03); }
100% { transform: scale(1); }
}
04数字が転がるいいねボタン
何が見えるか
数字の窓は高さを一行分に固定し、その中に古い値と新しい値を縦に積んでいます。いいねを押しても textContent は書き換えず、積んだ列を22px 持ち上げて新しい値を下から見せます。

<main class="stage">
<div class="lk is-demo">
<button class="lk__btn" id="lk-btn" type="button" aria-pressed="false" aria-label="いいねを押す">
<span class="lk__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
<span class="lk__win" aria-live="polite">
<span class="lk__roll">
<span class="lk__n">1,248</span>
<span class="lk__n">1,249</span>
</span>
</span>
</button>
<p class="lk__note">数字は入れ替わらず転がります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-orange;
@include stage($stage-paper);
.lk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lk__btn {
box-sizing: border-box;
margin: 0;
padding: $sp-2 $sp-5;
display: inline-flex;
align-items: center;
gap: $sp-2;
height: 48px;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background-color: transparent;
color: $subject-ink;
cursor: pointer;
}
.lk__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 3px; }
.lk__heart { display: inline-flex; flex: 0 0 auto; }
.lk__heart svg { display: block; }
// 숫자 창 — 높이를 한 줄로 못 박고 그 안에서 두 값이 세로로 오간다
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll {
display: block;
transform: translateY(0);
}
.lk__n {
display: block;
height: 22px;
font-size: 15px;
line-height: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
// 사람이 실제로 누른 경로
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] { border-color: $color; color: $color; }
.lk__btn.is-beat .lk__heart {
animation-name: lk-beat;
animation-duration: $dur-base;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes lk-beat {
0% { transform: scale(1); }
35% { transform: scale(1.45); }
70% { transform: scale(.92); }
100% { transform: scale(1); }
}
.lk__note {
margin: 0;
max-width: 260px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 35% 에 누르고 80% 에 다시 눌러 되돌린다. 굴림은 transform 이라 중간 프레임이 유령이 되지 않는다 ──
.lk.is-demo .lk__roll {
animation-name: lk-roll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lk-roll {
0% { transform: translateY(0); }
32% { transform: translateY(0); }
44% { transform: translateY(-22px); }
78% { transform: translateY(-22px); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
.lk.is-demo .lk__heart {
animation-name: lk-beat-loop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lk-beat-loop {
0% { transform: scale(1); }
32% { transform: scale(1); }
40% { transform: scale(1.5); }
50% { transform: scale(.94); }
58% { transform: scale(1); }
78% { transform: scale(1); }
84% { transform: scale(1.2); }
90% { transform: scale(1); }
100% { transform: scale(1); }
}
.lk.is-demo .lk__btn {
animation-name: lk-skin;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lk-skin {
0% { border-color: $subject-ink; color: $subject-ink; }
36% { border-color: $color; color: $color; }
82% { border-color: $subject-ink; color: $subject-ink; }
100% { border-color: $subject-ink; color: $subject-ink; }
}
@media (max-width: 340px) {
.lk { gap: $sp-2; }
.lk__btn { height: 44px; padding: $sp-2 $sp-4; }
.lk__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.lk.is-demo .lk__roll,
.lk.is-demo .lk__heart,
.lk.is-demo .lk__btn,
.lk__btn.is-beat .lk__heart { animation: none !important; }
.lk:not(.is-demo) .lk__roll { transition: none !important; }
}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);
}
.lk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lk__btn {
box-sizing: border-box;
margin: 0;
padding: 8px 20px;
display: inline-flex;
align-items: center;
gap: 8px;
height: 48px;
border: 2px solid #17141a;
border-radius: 999px;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.lk__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.lk__heart {
display: inline-flex;
flex: 0 0 auto;
}
.lk__heart svg {
display: block;
}
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll {
display: block;
transform: translateY(0);
}
.lk__n {
display: block;
height: 22px;
font-size: 15px;
line-height: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.lk:not(.is-demo) .lk__roll {
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.lk:not(.is-demo) .lk__btn[aria-pressed=true] .lk__roll {
transform: translateY(-22px);
}
.lk:not(.is-demo) .lk__btn[aria-pressed=true] {
border-color: #ff4d1f;
color: #ff4d1f;
}
.lk__btn.is-beat .lk__heart {
animation-name: lk-beat;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes lk-beat {
0% {
transform: scale(1);
}
35% {
transform: scale(1.45);
}
70% {
transform: scale(0.92);
}
100% {
transform: scale(1);
}
}
.lk__note {
margin: 0;
max-width: 260px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.lk.is-demo .lk__roll {
animation-name: lk-roll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes lk-roll {
0% {
transform: translateY(0);
}
32% {
transform: translateY(0);
}
44% {
transform: translateY(-22px);
}
78% {
transform: translateY(-22px);
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.lk.is-demo .lk__heart {
animation-name: lk-beat-loop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes lk-beat-loop {
0% {
transform: scale(1);
}
32% {
transform: scale(1);
}
40% {
transform: scale(1.5);
}
50% {
transform: scale(0.94);
}
58% {
transform: scale(1);
}
78% {
transform: scale(1);
}
84% {
transform: scale(1.2);
}
90% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.lk.is-demo .lk__btn {
animation-name: lk-skin;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lk-skin {
0% {
border-color: #17141a;
color: #17141a;
}
36% {
border-color: #ff4d1f;
color: #ff4d1f;
}
82% {
border-color: #17141a;
color: #17141a;
}
100% {
border-color: #17141a;
color: #17141a;
}
}
@media (max-width: 340px) {
.lk {
gap: 8px;
}
.lk__btn {
height: 44px;
padding: 8px 16px;
}
.lk__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.lk.is-demo .lk__roll,
.lk.is-demo .lk__heart,
.lk.is-demo .lk__btn,
.lk__btn.is-beat .lk__heart {
animation: none !important;
}
.lk:not(.is-demo) .lk__roll {
transition: none !important;
}
}// 실제 동작: 누른 상태와 숫자를 따로 두지 않는다. aria-pressed 하나가 하트 색과 굴림 위치를 같이 정한다
var root = document.querySelector('.lk');
var btn = document.getElementById('lk-btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
btn.classList.remove('is-beat');
void btn.offsetWidth; // 연달아 눌러도 하트 반동이 매번 돌게 리플로를 강제한다
btn.classList.add('is-beat');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LikeCountButton.module.scss";
export type LikeCountButtonProps = {
label?: string;
note?: string;
count?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 1248 → "1,248" — 세 자리마다 쉼표
function fmt(n: number): string {
const s = String(n);
let out = "";
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) out += ",";
out += s.charAt(i);
}
return out;
}
export default function LikeCountButton({
label = "Like this post",
note = "The number rolls up instead of being swapped",
count = 1248,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: LikeCountButtonProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [beat, setBeat] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누른 상태 하나가 하트 색과 굴림 위치를 같이 정한다
function press() {
setDemo(false);
setOn((v) => !v);
setBeat((v) => v + 1); // key 를 바꿔 연달아 눌러도 하트 반동이 매번 다시 돈다
}
return (
<div className={`${styles.lk} ${demo ? styles.isDemo : ""}`} style={vars}>
<button className={styles.btn} type="button" aria-pressed={on} aria-label={label} onClick={press}>
<span key={beat} className={`${styles.heart} ${demo ? "" : styles.isBeat}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
</span>
<span className={styles.win} aria-live="polite">
<span className={styles.roll}>
<span className={styles.n}>{fmt(count)}</span>
<span className={styles.n}>{fmt(count + 1)}</span>
</span>
</span>
</button>
<p className={styles.note}>{note}</p>
</div>
);
}04 · 数字が転がるいいねボタン — 計測 累積面積 1.325% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
桁ごとに文字幅が違うと窓が揺れるので、font-variant-numeric: tabular-nums で幅を揃えます。
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll { display: block; transform: translateY(0); }
.lk__n { display: block; height: 22px; font-variant-numeric: tabular-nums; }
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }
05ボタン自身が進捗バーになる
画面で起きること
パーセントは変数一つにだけ書き、満ちる幅も画面に出る数字も同じ変数を読みます。ところが --p: 0 と置くだけでは、ブラウザがその値を文字としてしか扱わないので、0から100へ滑らかに変化せず飛び飛びになります。

<main class="stage">
<div class="dl is-demo">
<button class="dl__btn" id="dl-btn" type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="dl__fill" aria-hidden="true"></span>
<span class="dl__face" data-face="idle">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z"/></g></svg></span>
<span class="dl__t">ダウンロード</span>
</span>
<span class="dl__face" data-face="busy">
<span class="dl__pct"></span>
</span>
<span class="dl__face" data-face="done">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="dl__t">完了しました</span>
</span>
</button>
<p class="dl__note">ボタンの内側がそのまま進捗バーです</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
// 등록하지 않은 커스텀 속성은 브라우저가 글자로만 보고 보간하지 않는다 — 숫자로 등록해야 0 에서 100 으로 흐른다
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
box-shadow: $shadow-raised;
}
.dl__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 채움 — 단일 변수 --p 가 그라디언트의 색 경계 자리를 정한다
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.dl__ic { display: inline-flex; flex: 0 0 auto; }
.dl__ic svg { display: block; }
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 같은 변수를 숫자로도 읽는다 — 카운터에 넣으면 글자가 된다
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
// 사람이 실제로 누른 경로
.dl:not(.is-demo) .dl__btn[data-state="idle"] .dl__face[data-face="idle"],
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"],
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__face[data-face="done"] { opacity: 1; }
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"] { color: $subject-cream; }
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__fill { background-image: none; background-color: $subject-mint; }
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
}
// ── 자동 루프: 12% 에 시작해 78% 에 100% 를 채우고 82% 에 완료로 끊었다가 88% 에 원복 ──
// --p 는 등속으로 흐르고, 완료 색은 한 프레임에 끊는다 — 한 요소에 두 애니메이션을 겹쳐 건다
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% { --p: 0; }
12% { --p: 0; }
78% { --p: 100; }
100% { --p: 100; }
}
@keyframes dl-skin {
0% { background-color: $subject-cream; }
82% { background-color: $subject-mint; }
88% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
.dl.is-demo .dl__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face="idle"] { animation-name: dl-idle; }
.dl.is-demo .dl__face[data-face="busy"] { animation-name: dl-busy; }
.dl.is-demo .dl__face[data-face="done"] { animation-name: dl-done; }
@keyframes dl-idle {
0% { opacity: 1; }
12% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes dl-busy {
0% { opacity: 0; color: $subject-ink; }
12% { opacity: 1; color: $subject-ink; }
40% { opacity: 1; color: $subject-cream; }
82% { opacity: 0; color: $subject-cream; }
100% { opacity: 0; color: $subject-cream; }
}
@keyframes dl-done {
0% { opacity: 0; }
82% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 완료로 끊는 동안은 채움 띠를 걷어 버튼 자체 색이 그대로 보이게 한다
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% { opacity: 1; }
82% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.dl { gap: $sp-2; }
.dl__btn { height: 44px; }
.dl__t { font-size: 13px; line-height: 18px; }
.dl__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill { animation: none !important; }
.dl.is-demo .dl__face[data-face="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dl__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, #2f6df6 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.dl__ic {
display: inline-flex;
flex: 0 0 auto;
}
.dl__ic svg {
display: block;
}
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dl:not(.is-demo) .dl__btn[data-state=idle] .dl__face[data-face=idle],
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy],
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__face[data-face=done] {
opacity: 1;
}
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy] {
color: #fff7e6;
}
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__fill {
background-image: none;
background-color: #4cd4a6;
}
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
}
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: 2s, 2s;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% {
--p: 0;
}
12% {
--p: 0;
}
78% {
--p: 100;
}
100% {
--p: 100;
}
}
@keyframes dl-skin {
0% {
background-color: #fff7e6;
}
82% {
background-color: #4cd4a6;
}
88% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
.dl.is-demo .dl__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face=idle] {
animation-name: dl-idle;
}
.dl.is-demo .dl__face[data-face=busy] {
animation-name: dl-busy;
}
.dl.is-demo .dl__face[data-face=done] {
animation-name: dl-done;
}
@keyframes dl-idle {
0% {
opacity: 1;
}
12% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-busy {
0% {
opacity: 0;
color: #17141a;
}
12% {
opacity: 1;
color: #17141a;
}
40% {
opacity: 1;
color: #fff7e6;
}
82% {
opacity: 0;
color: #fff7e6;
}
100% {
opacity: 0;
color: #fff7e6;
}
}
@keyframes dl-done {
0% {
opacity: 0;
}
82% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% {
opacity: 1;
}
82% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.dl {
gap: 8px;
}
.dl__btn {
height: 44px;
}
.dl__t {
font-size: 13px;
line-height: 18px;
}
.dl__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill {
animation: none !important;
}
.dl.is-demo .dl__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 퍼센트를 CSS 변수 --p 하나에만 적는다. 채움 너비도 숫자도 그 변수에서 나온다
var root = document.querySelector('.dl');
var btn = document.getElementById('dl-btn');
var timer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (btn.getAttribute('data-state') === 'busy') return;
var p = 0;
btn.setAttribute('data-state', 'busy');
clearInterval(timer);
timer = setInterval(function () {
p = Math.min(100, p + 4);
btn.style.setProperty('--p', p);
btn.setAttribute('aria-valuenow', String(p));
if (p === 100) { clearInterval(timer); btn.setAttribute('data-state', 'done'); }
}, 80);
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DownloadProgressButton.module.scss";
export type DownloadProgressButtonProps = {
label?: string;
doneLabel?: string;
note?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p"?: string;
};
type State = "idle" | "busy" | "done";
const STEP = 4; // 한 번에 올리는 퍼센트
const TICK = 80; // 올리는 간격(ms)
export default function DownloadProgressButton({
label = "Download",
doneLabel = "Saved",
note = "The inside of the button is the progress bar",
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: DownloadProgressButtonProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<State>("idle");
const [p, setP] = useState(0);
// 퍼센트를 --p 하나에만 적는다 — 채움 너비도 숫자도 그 변수에서 나온다
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const btnVars: Vars = demo ? {} : { "--p": String(p) };
useEffect(() => {
if (state !== "busy") return;
let v = 0;
setP(0);
const id = setInterval(() => {
v = Math.min(100, v + STEP);
setP(v);
if (v === 100) {
clearInterval(id);
setState("done");
}
}, TICK);
return () => clearInterval(id);
}, [state]);
function start() {
setDemo(false);
if (state === "busy") return;
setState("busy");
}
return (
<div className={`${styles.dl} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={styles.btn}
type="button"
style={btnVars}
data-state={state}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={demo ? 0 : p}
onClick={start}
>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z" />
</g>
</svg>
</span>
<span className={styles.t}>{label}</span>
</span>
<span className={styles.face} data-face="busy">
<span className={styles.pct} />
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
<p className={styles.note}>{note}</p>
</div>
);
}05 · ボタン自身が進捗バーになる — 計測 累積面積 9.269% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
@property で整数として登録して初めて補間され、同じ変数を counter-reset に渡せば数字としても表示できます。
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl__fill {
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-variant-numeric: tabular-nums;
}
06長押しで初めて消える削除
どんな動きか
押している間だけ周りの輪が stroke-dashoffset で一周描かれ、描き切ったときだけ削除が実行されます。途中で手を離すと同じトランジションがそのまま巻き戻り、何もなかったことになります。

<main class="stage">
<div class="hd is-demo">
<div class="hd__dial">
<svg class="hd__ring" viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle class="hd__track" cx="44" cy="44" r="40" />
<circle class="hd__arc" cx="44" cy="44" r="40" />
</svg>
<button class="hd__btn" id="hd-btn" type="button" aria-label="長押しで削除">
<span class="hd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
</button>
</div>
<p class="hd__log" role="status" aria-live="polite">
<span class="hd__word" data-word="idle">長押しで削除</span>
<span class="hd__word" data-word="done">削除しました</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1200ms;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-yellow);
$circumference: 251.33; // 2 × π × 40 — 고리를 한 바퀴로 만드는 길이
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg); // 12시 방향에서 그리기 시작한다
}
.hd__track {
fill: none;
stroke: $subject-cream;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: $color;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: $circumference;
stroke-dashoffset: $circumference;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: $subject-ink;
color: $subject-cream;
cursor: pointer;
touch-action: none; // 길게 누르는 동안 브라우저가 스크롤로 가로채지 않게
}
.hd__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.hd__ic { display: inline-flex; }
.hd__ic svg { display: block; }
// 사람이 실제로 누른 경로 — 누르는 동안 차고, 떼면 같은 길로 되감긴다
.hd:not(.is-demo) .hd__arc { transition: stroke-dashoffset $duration $easing; }
.hd:not(.is-demo).is-holding .hd__arc { stroke-dashoffset: 0; }
.hd:not(.is-demo).is-holding .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__arc { stroke-dashoffset: 0; }
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: $subject-ink;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word="idle"] { opacity: 1; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="idle"] { opacity: 0; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="done"] { opacity: 1; }
// ── 자동 루프: 10% 에 눌러 62% 까지 한 바퀴를 그리고, 68% 에 삭제로 끊었다가 86% 에 되감는다 ──
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% { stroke-dashoffset: $circumference; }
10% { stroke-dashoffset: $circumference; }
62% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
90% { stroke-dashoffset: $circumference; }
100% { stroke-dashoffset: $circumference; }
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% { background-color: $subject-ink; transform: scale(1); }
10% { background-color: $subject-ink; transform: scale(.92); }
68% { background-color: $color; transform: scale(1.06); }
86% { background-color: $subject-ink; transform: scale(1); }
100% { background-color: $subject-ink; transform: scale(1); }
}
.hd.is-demo .hd__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word="idle"] { animation-name: hd-idle; }
.hd.is-demo .hd__word[data-word="done"] { animation-name: hd-done; }
@keyframes hd-idle {
0% { opacity: 1; }
68% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes hd-done {
0% { opacity: 0; }
68% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.hd { gap: $sp-2; }
.hd__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word { animation: none !important; }
.hd:not(.is-demo) .hd__arc { transition: none !important; }
.hd.is-demo .hd__word[data-word="idle"] { opacity: 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);
}
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg);
}
.hd__track {
fill: none;
stroke: #fff7e6;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: #ff4d1f;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: 251.33;
stroke-dashoffset: 251.33;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: #17141a;
color: #fff7e6;
cursor: pointer;
touch-action: none;
}
.hd__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.hd__ic {
display: inline-flex;
}
.hd__ic svg {
display: block;
}
.hd:not(.is-demo) .hd__arc {
transition: stroke-dashoffset 1200ms ease-out;
}
.hd:not(.is-demo).is-holding .hd__arc {
stroke-dashoffset: 0;
}
.hd:not(.is-demo).is-holding .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__arc {
stroke-dashoffset: 0;
}
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: #17141a;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word=idle] {
opacity: 1;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=idle] {
opacity: 0;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=done] {
opacity: 1;
}
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% {
stroke-dashoffset: 251.33;
}
10% {
stroke-dashoffset: 251.33;
}
62% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 0;
}
90% {
stroke-dashoffset: 251.33;
}
100% {
stroke-dashoffset: 251.33;
}
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% {
background-color: #17141a;
transform: scale(1);
}
10% {
background-color: #17141a;
transform: scale(0.92);
}
68% {
background-color: #ff4d1f;
transform: scale(1.06);
}
86% {
background-color: #17141a;
transform: scale(1);
}
100% {
background-color: #17141a;
transform: scale(1);
}
}
.hd.is-demo .hd__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word=idle] {
animation-name: hd-idle;
}
.hd.is-demo .hd__word[data-word=done] {
animation-name: hd-done;
}
@keyframes hd-idle {
0% {
opacity: 1;
}
68% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes hd-done {
0% {
opacity: 0;
}
68% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.hd {
gap: 8px;
}
.hd__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word {
animation: none !important;
}
.hd:not(.is-demo) .hd__arc {
transition: none !important;
}
.hd.is-demo .hd__word[data-word=idle] {
opacity: 1;
}
}// 실제 동작: 누르는 동안만 고리가 그려진다. 손을 떼면 되감기고, 한 바퀴를 다 그렸을 때만 삭제가 일어난다
var root = document.querySelector('.hd');
var btn = document.getElementById('hd-btn');
var arc = document.querySelector('.hd__arc');
var doneTimer = 0;
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
root.classList.add('is-holding');
clearTimeout(doneTimer);
doneTimer = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(doneTimer); root.classList.remove('is-holding'); });
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HoldToDeleteButton.module.scss";
export type HoldToDeleteButtonProps = {
label?: string;
doneLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// DOM 전역 타입을 끌어오지 않으려고 필요한 모양만 여기서 선언한다
type PtrEvt = { pointerId: number; currentTarget: { setPointerCapture(id: number): void } };
export default function HoldToDeleteButton({
label = "Hold to delete",
doneLabel = "Deleted",
duration = 1200,
easing = "ease-out",
color = "#ff4d1f",
}: HoldToDeleteButtonProps) {
const [demo, setDemo] = useState(true);
const [holding, setHolding] = useState(false);
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const stop = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
useEffect(() => stop, [stop]);
// 누르는 동안만 고리가 그려진다 — 한 바퀴를 다 그렸을 때만 삭제가 일어난다
function down(e: PtrEvt) {
setDemo(false);
setDone(false);
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
setHolding(true);
stop();
timer.current = setTimeout(() => {
setDone(true);
setHolding(false);
}, duration);
}
function release() {
stop();
setHolding(false);
}
return (
<div
className={`${styles.hd} ${demo ? styles.isDemo : ""} ${holding ? styles.isHolding : ""}`}
style={vars}
data-state={done ? "done" : "idle"}
>
<div className={styles.dial}>
<svg className={styles.ring} viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle className={styles.track} cx="44" cy="44" r="40" />
<circle className={styles.arc} cx="44" cy="44" r="40" />
</svg>
<button
className={styles.btn}
type="button"
aria-label={label}
onPointerDown={down}
onPointerUp={release}
onPointerCancel={release}
onPointerLeave={release}
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="26" height="26">
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
</svg>
</span>
</button>
</div>
<p className={styles.log} role="status" aria-live="polite">
<span className={styles.word} data-word="idle">{label}</span>
<span className={styles.word} data-word="done">{doneLabel}</span>
</p>
</div>
);
}06 · 長押しで初めて消える削除 — 計測 累積面積 4.224% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
押した瞬間に setPointerCapture を呼んでおかないと、指がボタンの外へ出たときに取り消しイベントを受け取れず、輪が勝手に満ち続けます。
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId);
root.classList.add('is-holding');
clearTimeout(doneTimer);
doneTimer = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(doneTimer); root.classList.remove('is-holding'); });
});
07主な操作と別の道が並ぶボタン
何が見えるか
一つの枠に入っていますが中身は button が二つで、一覧を開くのは右の矢印側だけです。左の広い側はいちばんよく使う操作をすぐ実行し、一覧を閉じるだけなので aria-expanded を true にはしません。

<main class="stage">
<div class="sp is-demo">
<div class="sp__wrap">
<div class="sp__group" role="group" aria-label="送信の選択">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="sp__t">今すぐ送信</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="送信方法をもっと見る">
<span class="sp__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
<li role="none"><button class="sp__item" type="button" role="menuitem">予約して送信</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">下書きに保存</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">送信前にプレビュー</button></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.sp {
box-sizing: border-box;
width: min(260px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 열린 목록은 절대 배치라 부모 높이에 안 잡힌다 — 목록 높이만큼 자리를 미리 비워 둔다
.sp__wrap {
position: relative;
padding-bottom: 116px;
}
.sp__group {
display: flex;
align-items: stretch;
height: 44px;
border-radius: $r-control;
background-color: $subject-cream;
box-shadow: $shadow-raised;
}
.sp__main {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
border: 0;
border-radius: $r-control 0 0 $r-control;
background-color: transparent;
color: $color;
cursor: pointer;
}
.sp__main[data-sent="1"] { background-color: $subject-mint; }
.sp__more {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
flex: 0 0 auto;
display: flex;
align-items: center;
border: 0;
border-left: 2px solid $stage-orange; // 한 테두리 안에서 두 칸을 가르는 선
border-radius: 0 $r-control $r-control 0;
background-color: transparent;
color: $color;
cursor: pointer;
}
.sp__main:focus-visible,
.sp__more:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.sp__ic,
.sp__caret { display: inline-flex; flex: 0 0 auto; }
.sp__ic svg,
.sp__caret svg { display: block; }
.sp__t {
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sp__menu {
position: absolute;
left: 0;
right: 0;
top: 52px;
margin: 0;
padding: $sp-1;
list-style: none;
border-radius: $r-card;
background-color: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
}
.sp__item {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
display: block;
width: 100%;
height: 32px;
border: 0;
border-radius: $r-sm;
background-color: transparent;
color: $color;
text-align: left;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transform: translateY(-16px);
}
.sp__item:hover { background-color: $stage-yellow; }
.sp__item:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// 사람이 실제로 연 경로
.sp:not(.is-demo)[data-open="1"] .sp__menu { opacity: 1; }
.sp:not(.is-demo)[data-open="1"] .sp__item { transform: translateY(0); transition: transform $duration $easing; }
.sp:not(.is-demo)[data-open="1"] .sp__caret { transform: rotate(180deg); }
.sp:not(.is-demo) .sp__caret { transition: transform $duration $easing; }
.sp__menu[aria-hidden],
.sp:not(.is-demo):not([data-open="1"]) .sp__menu { pointer-events: none; }
// ── 자동 루프: 8% 에 열고 **닫지 않는다**. 목록이 사라지는 프레임이 가장 크게 변해 대표 이미지가 빈 버튼이 됐다 ──
// 대신 열린 뒤에는 줄을 하나씩 훑어 움직임을 채운다.
.sp.is-demo .sp__menu {
animation-name: sp-open;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-open {
0% { opacity: 0; }
8% { opacity: 1; }
100% { opacity: 1; }
}
.sp.is-demo .sp__item {
animation-name: sp-slide, sp-walk;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sp.is-demo li:nth-child(1) .sp__item { animation-delay: 0s, 0s; }
.sp.is-demo li:nth-child(2) .sp__item { animation-delay: 140ms, 360ms; }
.sp.is-demo li:nth-child(3) .sp__item { animation-delay: 280ms, 720ms; }
@keyframes sp-slide {
0% { transform: translateY(-16px); }
8% { transform: translateY(-16px); }
34% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes sp-walk {
0% { background-color: transparent; }
40% { background-color: $stage-yellow; }
58% { background-color: transparent; }
100% { background-color: transparent; }
}
.sp.is-demo .sp__caret {
animation-name: sp-turn;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-turn {
0% { transform: rotate(0deg); }
6% { transform: rotate(0deg); }
26% { transform: rotate(180deg); }
100% { transform: rotate(180deg); }
}
@media (max-width: 340px) {
.sp__wrap { padding-bottom: 100px; }
.sp__group { height: 40px; }
.sp__t { font-size: 12px; line-height: 18px; }
.sp__menu { top: 44px; }
.sp__item { height: 28px; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__menu,
.sp.is-demo .sp__item,
.sp.is-demo .sp__caret { animation: none !important; }
.sp:not(.is-demo) .sp__item,
.sp:not(.is-demo) .sp__caret { transition: none !important; }
.sp.is-demo .sp__menu { opacity: 1; }
.sp.is-demo .sp__item { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
box-sizing: border-box;
width: min(260px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp__wrap {
position: relative;
padding-bottom: 116px;
}
.sp__group {
display: flex;
align-items: stretch;
height: 44px;
border-radius: 12px;
background-color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sp__main {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 12px 0 0 12px;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.sp__main[data-sent="1"] {
background-color: #4cd4a6;
}
.sp__more {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
flex: 0 0 auto;
display: flex;
align-items: center;
border: 0;
border-left: 2px solid #ff4d1f;
border-radius: 0 12px 12px 0;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.sp__main:focus-visible,
.sp__more:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.sp__ic,
.sp__caret {
display: inline-flex;
flex: 0 0 auto;
}
.sp__ic svg,
.sp__caret svg {
display: block;
}
.sp__t {
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sp__menu {
position: absolute;
left: 0;
right: 0;
top: 52px;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 16px;
background-color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
}
.sp__item {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
display: block;
width: 100%;
height: 32px;
border: 0;
border-radius: 8px;
background-color: transparent;
color: #17141a;
text-align: left;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transform: translateY(-16px);
}
.sp__item:hover {
background-color: #ffd23f;
}
.sp__item:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.sp:not(.is-demo)[data-open="1"] .sp__menu {
opacity: 1;
}
.sp:not(.is-demo)[data-open="1"] .sp__item {
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp:not(.is-demo)[data-open="1"] .sp__caret {
transform: rotate(180deg);
}
.sp:not(.is-demo) .sp__caret {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__menu[aria-hidden],
.sp:not(.is-demo):not([data-open="1"]) .sp__menu {
pointer-events: none;
}
.sp.is-demo .sp__menu {
animation-name: sp-open;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-open {
0% {
opacity: 0;
}
8% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sp.is-demo .sp__item {
animation-name: sp-slide, sp-walk;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sp.is-demo li:nth-child(1) .sp__item {
animation-delay: 0s, 0s;
}
.sp.is-demo li:nth-child(2) .sp__item {
animation-delay: 140ms, 360ms;
}
.sp.is-demo li:nth-child(3) .sp__item {
animation-delay: 280ms, 720ms;
}
@keyframes sp-slide {
0% {
transform: translateY(-16px);
}
8% {
transform: translateY(-16px);
}
34% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes sp-walk {
0% {
background-color: transparent;
}
40% {
background-color: #ffd23f;
}
58% {
background-color: transparent;
}
100% {
background-color: transparent;
}
}
.sp.is-demo .sp__caret {
animation-name: sp-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-turn {
0% {
transform: rotate(0deg);
}
6% {
transform: rotate(0deg);
}
26% {
transform: rotate(180deg);
}
100% {
transform: rotate(180deg);
}
}
@media (max-width: 340px) {
.sp__wrap {
padding-bottom: 100px;
}
.sp__group {
height: 40px;
}
.sp__t {
font-size: 12px;
line-height: 18px;
}
.sp__menu {
top: 44px;
}
.sp__item {
height: 28px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__menu,
.sp.is-demo .sp__item,
.sp.is-demo .sp__caret {
animation: none !important;
}
.sp:not(.is-demo) .sp__item,
.sp:not(.is-demo) .sp__caret {
transition: none !important;
}
.sp.is-demo .sp__menu {
opacity: 1;
}
.sp.is-demo .sp__item {
transform: translateY(0);
}
}// 실제 동작: 왼쪽 칸은 목록을 열지 않는다. 오른쪽 화살표 칸만 aria-expanded 를 뒤집는다
var root = document.querySelector('.sp');
var more = document.getElementById('sp-more');
var main = document.getElementById('sp-main');
function setOpen(on) {
more.setAttribute('aria-expanded', String(on));
root.setAttribute('data-open', on ? '1' : '0');
}
more.addEventListener('click', function () {
root.classList.remove('is-demo');
setOpen(more.getAttribute('aria-expanded') !== 'true');
});
main.addEventListener('click', function () {
root.classList.remove('is-demo');
setOpen(false);
main.setAttribute('data-sent', '1');
});
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); more.focus({ preventScroll: true }); }
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SplitMenuButton.module.scss";
export type SplitMenuButtonProps = {
mainLabel?: string;
items?: string[];
groupLabel?: string;
moreLabel?: string;
id?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// DOM 전역 타입을 react 에서 가져오지 않는다 — 쓰는 속성만 여기에 적는다
type KeyEvt = { key: string };
const SEND_ICON = (
<g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" /><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" /></g>
);
const CARET_ICON = (
<g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2" /><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" /></g>
);
export default function SplitMenuButton({
mainLabel = "Send now",
items = ["Schedule send", "Save to drafts", "Preview first"],
groupLabel = "Send options",
moreLabel = "More ways to send",
id = "sp",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
}: SplitMenuButtonProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [open, setOpen] = useState(false);
const [sent, setSent] = useState(false);
const moreRef = useRef<HTMLButtonElement | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const menuId = `${id}-menu`;
const moreId = `${id}-more`;
// 왼쪽 칸은 목록을 열지 않는다. 오른쪽 화살표 칸만 aria-expanded 를 뒤집는다
const toggle = useCallback(() => {
setDemo(false);
setOpen((v) => !v);
}, []);
const send = useCallback(() => {
setDemo(false);
setOpen(false);
setSent(true);
}, []);
const onKeyDown = useCallback((e: KeyEvt) => {
if (e.key !== "Escape") return;
setOpen(false);
// preventScroll — iframe 안에서 부모 페이지가 끌려가지 않게
moreRef.current?.focus({ preventScroll: true });
}, []);
return (
<div
className={`${styles.sp} ${demo ? styles.isDemo : ""}`}
style={vars}
data-open={open ? "1" : "0"}
onKeyDown={onKeyDown}
>
<div className={styles.wrap}>
<div className={styles.group} role="group" aria-label={groupLabel}>
<button className={styles.main} type="button" data-sent={sent ? "1" : "0"} onClick={send}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{SEND_ICON}</svg>
</span>
<span className={styles.t}>{mainLabel}</span>
</button>
<button
ref={moreRef}
className={styles.more}
id={moreId}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
aria-label={moreLabel}
onClick={toggle}
>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{CARET_ICON}</svg>
</span>
</button>
</div>
<ul className={styles.menu} id={menuId} role="menu" aria-labelledby={moreId}>
{items.map((label) => (
<li key={label} role="none">
<button className={styles.item} type="button" role="menuitem">{label}</button>
</li>
))}
</ul>
</div>
</div>
);
}07 · 主な操作と別の道が並ぶボタン — 計測 累積面積 24.579% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
一覧そのものは role="menu" で各行が role="menuitem" なので、キーボードからも同じ経路で開けます。
<div class="sp__group" role="group" aria-label="送信の選択">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__t">今すぐ送信</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="送信方法をもっと見る">
<span class="sp__caret" aria-hidden="true"></span>
</button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
<li role="none"><button class="sp__item" type="button" role="menuitem">予約して送信</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">下書きに保存</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">送信前にプレビュー</button></li>
</ul>
08選んだ区画へ滑る錠剤
画面で起きること
三つの区画は flex: 0 0 auto で幅80px に固定してあり、錠剤はその倍数だけ動きます。均等配分の flex: 1 1 0 と固定の移動幅を混ぜると、実際に描かれた区画の幅が見積もりとずれて、錠剤が隣のラベルにはみ出します。

<main class="stage">
<div class="sg is-demo">
<div class="sg__bar" role="radiogroup" aria-label="支払い周期を選ぶ" data-at="0">
<span class="sg__pill" aria-hidden="true"></span>
<button class="sg__seg" type="button" role="radio" aria-checked="true" data-at="0">月額</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="1">年額</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="2">買い切り</button>
</div>
<p class="sg__note">区画の幅を固定しないと錠剤がずれます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-paper);
$segment-width: 80px; // 세 칸을 같은 폭으로 못 박는다. 알약이 이 값의 배수만큼만 움직이므로 어긋날 자리가 없다
.sg {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__bar {
position: relative;
box-sizing: border-box;
display: flex;
padding: $sp-1;
border-radius: $r-pill;
background-color: $subject-ink;
}
.sg__pill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: $segment-width;
border-radius: $r-pill;
background-color: $color;
transform: translateX(0);
}
.sg__seg {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
flex: 0 0 auto; // 균등분배(flex: 1 1 0)와 섞으면 알약의 고정 이동폭과 실제 칸 폭이 어긋난다
width: $segment-width;
height: 36px;
border: 0;
border-radius: $r-pill;
background-color: transparent;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.sg__seg:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.sg__seg[aria-checked="true"] { color: $subject-ink; }
// 사람이 실제로 고른 경로
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($segment-width); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($segment-width * 2); }
.sg__note {
margin: 0;
max-width: 288px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 알약은 transform 으로 미끄러지고, 글자 색만 한 프레임에 끊는다 ──
.sg.is-demo .sg__pill {
animation-name: sg-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sg-slide {
0% { transform: translateX(0); }
18% { transform: translateX(0); }
34% { transform: translateX($segment-width); }
50% { transform: translateX($segment-width); }
64% { transform: translateX($segment-width * 2); }
76% { transform: translateX($segment-width * 2); }
90% { transform: translateX(0); }
100% { transform: translateX(0); }
}
.sg.is-demo .sg__seg {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__seg[data-at="0"] { animation-name: sg-a; }
.sg.is-demo .sg__seg[data-at="1"] { animation-name: sg-b; }
.sg.is-demo .sg__seg[data-at="2"] { animation-name: sg-c; }
@keyframes sg-a {
0% { color: $subject-ink; }
26% { color: $subject-cream; }
84% { color: $subject-ink; }
100% { color: $subject-ink; }
}
@keyframes sg-b {
0% { color: $subject-cream; }
26% { color: $subject-ink; }
58% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@keyframes sg-c {
0% { color: $subject-cream; }
58% { color: $subject-ink; }
84% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@media (max-width: 340px) {
.sg { gap: $sp-2; }
.sg__note { font-size: 11px; line-height: 16px; max-width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill,
.sg.is-demo .sg__seg { animation: none !important; }
.sg:not(.is-demo) .sg__pill { transition: none !important; }
.sg.is-demo .sg__seg[data-at="0"] { color: $subject-ink; }
}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);
}
.sg {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__bar {
position: relative;
box-sizing: border-box;
display: flex;
padding: 4px;
border-radius: 999px;
background-color: #17141a;
}
.sg__pill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 80px;
border-radius: 999px;
background-color: #ffd23f;
transform: translateX(0);
}
.sg__seg {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
flex: 0 0 auto;
width: 80px;
height: 36px;
border: 0;
border-radius: 999px;
background-color: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.sg__seg:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.sg__seg[aria-checked=true] {
color: #17141a;
}
.sg:not(.is-demo) .sg__pill {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill {
transform: translateX(80px);
}
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill {
transform: translateX(160px);
}
.sg__note {
margin: 0;
max-width: 288px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.sg.is-demo .sg__pill {
animation-name: sg-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sg-slide {
0% {
transform: translateX(0);
}
18% {
transform: translateX(0);
}
34% {
transform: translateX(80px);
}
50% {
transform: translateX(80px);
}
64% {
transform: translateX(160px);
}
76% {
transform: translateX(160px);
}
90% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
.sg.is-demo .sg__seg {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__seg[data-at="0"] {
animation-name: sg-a;
}
.sg.is-demo .sg__seg[data-at="1"] {
animation-name: sg-b;
}
.sg.is-demo .sg__seg[data-at="2"] {
animation-name: sg-c;
}
@keyframes sg-a {
0% {
color: #17141a;
}
26% {
color: #fff7e6;
}
84% {
color: #17141a;
}
100% {
color: #17141a;
}
}
@keyframes sg-b {
0% {
color: #fff7e6;
}
26% {
color: #17141a;
}
58% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@keyframes sg-c {
0% {
color: #fff7e6;
}
58% {
color: #17141a;
}
84% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@media (max-width: 340px) {
.sg {
gap: 8px;
}
.sg__note {
font-size: 11px;
line-height: 16px;
max-width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill,
.sg.is-demo .sg__seg {
animation: none !important;
}
.sg:not(.is-demo) .sg__pill {
transition: none !important;
}
.sg.is-demo .sg__seg[data-at="0"] {
color: #17141a;
}
}// 실제 동작: 고른 칸은 aria-checked 로 적고, 알약은 칸 번호 × 칸 폭만큼만 옮긴다
var root = document.querySelector('.sg');
var bar = root.querySelector('.sg__bar');
var segs = [].slice.call(root.querySelectorAll('.sg__seg'));
function select(i) {
bar.setAttribute('data-at', String(i));
segs.forEach(function (b, k) { b.setAttribute('aria-checked', String(k === i)); });
}
segs.forEach(function (b, i) {
b.addEventListener('click', function () { root.classList.remove('is-demo'); select(i); });
});
bar.addEventListener('keydown', function (e) {
var i = segs.indexOf(document.activeElement);
if (i < 0 || (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft')) return;
e.preventDefault();
root.classList.remove('is-demo');
var n = (i + (e.key === 'ArrowRight' ? 1 : 2)) % 3;
select(n);
segs[n].focus({ preventScroll: true }); // iframe 안에서 부모 페이지가 끌려가지 않게
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SegmentedRadioGroup.module.scss";
export type SegmentedRadioGroupProps = {
labels?: [string, string, string]; // 알약 이동폭이 80px 고정이라 칸은 세 개다
groupLabel?: string;
note?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// DOM 전역 타입을 react 에서 가져오지 않는다 — 쓰는 속성만 여기에 적는다
type KeyEvt = { key: string; preventDefault(): void };
type Focusable = { focus(opt: { preventScroll: boolean }): void };
export default function SegmentedRadioGroup({
labels = ["Monthly", "Yearly", "Lifetime"],
groupLabel = "Choose a billing cycle",
note = "Pin the segment width so the pill never drifts",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
}: SegmentedRadioGroupProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [at, setAt] = useState(0);
const segs = useRef<(Focusable | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const pick = useCallback((i: number) => {
setDemo(false);
setAt(i);
}, []);
// 좌우 화살표로 이동 — 활성 요소를 뒤지지 않고 칸 번호를 그대로 쓴다
const onKeyDown = useCallback((e: KeyEvt, i: number) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
setDemo(false);
const n = (i + (e.key === "ArrowRight" ? 1 : labels.length - 1)) % labels.length;
setAt(n);
// preventScroll — iframe 안에서 부모 페이지가 끌려가지 않게
const segEls = segs.current;
if (segEls) segEls[n]?.focus({ preventScroll: true });
}, [labels.length]);
return (
<div className={`${styles.sg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.bar} role="radiogroup" aria-label={groupLabel} data-at={String(at)}>
<span className={styles.pill} aria-hidden="true" />
{labels.map((label, i) => (
<button
key={label}
ref={(el: Focusable | null) => { const segEls = segs.current; if (segEls) segEls[i] = el; }}
className={styles.seg}
type="button"
role="radio"
aria-checked={at === i}
data-at={String(i)}
onClick={() => pick(i)}
onKeyDown={(e: KeyEvt) => onKeyDown(e, i)}
>
{label}
</button>
))}
</div>
<p className={styles.note}>{note}</p>
</div>
);
}08 · 選んだ区画へ滑る錠剤 — 計測 累積面積 5.811% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
どの区画が選ばれているかは aria-checked に書き、文字色も同じ属性から決めます。
.sg__pill {
position: absolute;
width: $segment-width;
border-radius: $r-pill;
transform: translateX(0);
}
.sg__seg {
flex: 0 0 auto;
width: $segment-width;
height: 36px;
}
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($segment-width); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($segment-width * 2); }
09コピーしたと一瞬だけ言うボタン
どんな動きか
navigator.clipboard.writeText の処理が決着したあとにラベルが変わり、1.6秒後に静かに戻ります。権限がブロックされた環境でも人には反応が見えるべきなので、成功と失敗の両方に同じ仕上げ関数を渡します。

<main class="stage">
<div class="cp is-demo">
<div class="cp__row">
<code class="cp__code" id="cp-src">npm i godrich-ui</code>
<button class="cp__btn" id="cp-btn" type="button" aria-label="コマンドをクリップボードにコピー">
<span class="cp__ring" aria-hidden="true"></span>
<span class="cp__face" data-face="idle">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span class="cp__t">コピー</span>
</span>
<span class="cp__face" data-face="done">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="cp__t">コピー済み</span>
</span>
</button>
</div>
<p class="cp__live" role="status" aria-live="polite">
<span class="cp__word">コマンドをコピーしました</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 $sp-3;
height: 44px;
border-radius: $r-sm;
background-color: $subject-cream;
color: $subject-ink;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
}
.cp__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 복사된 순간에만 한 번 퍼지는 테두리 — 되돌아올 때는 없으므로 대표 프레임이 '복사됨' 쪽으로 잡힌다
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid $color;
border-radius: $r-control;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.cp__ic { display: inline-flex; flex: 0 0 auto; }
.cp__ic svg { display: block; }
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 사람이 실제로 누른 경로
.cp:not(.is-demo) .cp__face[data-face="idle"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="idle"] { opacity: 0; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="done"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__btn { background-color: $color; }
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word { opacity: 1; }
// ── 자동 루프: 35% 에 복사로 끊고, 되돌림은 76%(아이콘)·84%(색·라벨) 로 나눠 한 번에 커지지 않게 한다 ──
.cp.is-demo .cp__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face="idle"] { animation-name: cp-idle; }
.cp.is-demo .cp__face[data-face="done"] { animation-name: cp-done; }
@keyframes cp-idle {
0% { opacity: 1; }
35% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cp-done {
0% { opacity: 0; }
35% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% { background-color: $subject-cream; }
35% { background-color: $color; }
84% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
@keyframes cp-tap {
0% { transform: scale(1); }
30% { transform: scale(1); }
35% { transform: scale(.92); }
44% { transform: scale(1.04); }
54% { transform: scale(1); }
100% { transform: scale(1); }
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% { opacity: 0; transform: scale(1); }
35% { opacity: 1; transform: scale(1); }
52% { opacity: 0; transform: scale(1.4); }
100% { opacity: 0; transform: scale(1.4); }
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% { opacity: 0; }
35% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.cp { gap: $sp-2; }
.cp__row { gap: $sp-1; }
.cp__code { font-size: 11px; line-height: 16px; padding: 0 $sp-2; }
.cp__btn { width: 76px; }
.cp__t { font-size: 11px; line-height: 16px; }
.cp__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word { animation: none !important; }
.cp.is-demo .cp__face[data-face="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: 8px;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 12px;
height: 44px;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
}
.cp__btn:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 3px;
}
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid #4cd4a6;
border-radius: 12px;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.cp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.cp__ic svg {
display: block;
}
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp:not(.is-demo) .cp__face[data-face=idle] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=idle] {
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=done] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__btn {
background-color: #4cd4a6;
}
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word {
opacity: 1;
}
.cp.is-demo .cp__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face=idle] {
animation-name: cp-idle;
}
.cp.is-demo .cp__face[data-face=done] {
animation-name: cp-done;
}
@keyframes cp-idle {
0% {
opacity: 1;
}
35% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cp-done {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% {
background-color: #fff7e6;
}
35% {
background-color: #4cd4a6;
}
84% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
@keyframes cp-tap {
0% {
transform: scale(1);
}
30% {
transform: scale(1);
}
35% {
transform: scale(0.92);
}
44% {
transform: scale(1.04);
}
54% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% {
opacity: 0;
transform: scale(1);
}
35% {
opacity: 1;
transform: scale(1);
}
52% {
opacity: 0;
transform: scale(1.4);
}
100% {
opacity: 0;
transform: scale(1.4);
}
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cp {
gap: 8px;
}
.cp__row {
gap: 4px;
}
.cp__code {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
}
.cp__btn {
width: 76px;
}
.cp__t {
font-size: 11px;
line-height: 16px;
}
.cp__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word {
animation: none !important;
}
.cp.is-demo .cp__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 같은 말이 읽히는 영역으로도 한 번 나간다
var root = document.querySelector('.cp');
var btn = document.getElementById('cp-btn');
var src = document.getElementById('cp-src');
var resetTimer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var finish = function () {
root.setAttribute('data-copied', '1');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(finish, finish);
} else {
finish();
}
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyFeedbackButton.module.scss";
export type CopyFeedbackButtonProps = {
code?: string;
idleLabel?: string;
doneLabel?: string;
liveMessage?: string;
buttonLabel?: string;
holdMs?: number;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
const COPY_ICON = (
<g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g>
);
const DONE_ICON = (
<g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>
);
export default function CopyFeedbackButton({
code = "npm i godrich-ui",
idleLabel = "Copy",
doneLabel = "Copied",
liveMessage = "Command copied",
buttonLabel = "Copy the command to the clipboard",
holdMs = 1600,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
}: CopyFeedbackButtonProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
useEffect(() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
}, []);
// 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 실패해도 같은 길로 되돌아온다
const copy = useCallback(() => {
setDemo(false);
const finish = () => {
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), holdMs);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(finish, finish);
} else {
finish();
}
}, [code, holdMs]);
return (
<div
className={`${styles.cp} ${demo ? styles.isDemo : ""}`}
style={vars}
data-copied={copied ? "1" : "0"}
>
<div className={styles.row}>
<code className={styles.code}>{code}</code>
<button className={styles.btn} type="button" aria-label={buttonLabel} onClick={copy}>
<span className={styles.ring} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{COPY_ICON}</svg>
</span>
<span className={styles.t}>{idleLabel}</span>
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{DONE_ICON}</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
</div>
{/* 같은 말이 읽히는 영역으로도 한 번 나간다 */}
<p className={styles.live} role="status" aria-live="polite">
<span className={styles.word}>{liveMessage}</span>
</p>
</div>
);
}09 · コピーしたと一瞬だけ言うボタン — 計測 累積面積 4.763% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
一瞬だけ変わるラベルは目にしか届かないため、同じ言葉が role="status" の領域へも一度だけ流れます。
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var finish = function () {
root.setAttribute('data-copied', '1');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(finish, finish);
} else {
finish();
}
});
どこで壊れるか — 落とし穴
状態が変わる部品は、まず代表画像で崩れます。デモごとに24枚のフレームを取り、そのうち前の一枚と最も大きく変わった一枚を代表に選ぶのですが、07の最初の版はループで一覧を開いてからまた閉じていました。一覧が消えるフレームは開くフレームより必ず大きく変わるので、代表画像はオレンジ地に閉じたボタンが一つ残っただけの場面になりました。直し方は、ループでは一覧を閉じないことです。8%で一度だけ開いたらそのままにして、行ごとに違う遅延で translateY を戻し、そのあとは背景色で行を一つずつなぞらせました。結果は累積面積24.58%、動いたフレーム12/23で、代表画像は一覧が開いた場面になりました。24枚は2秒を等分した標本なので、1枚あたり4.1667%の刻みになります。
二つ目は、カットとフレーム数の綱引きです。状態が切り替わる瞬間を steps(1, end) で断つと半透明に重なった幽霊フレームは消えますが、カットは差のあるフレームを二枚しか作りません。そこで九つすべてに、カットの隣に transform で動く小さな動きを一つずつ添えました。01は回り続けるスピナー、03は scale の反動、09はコピーされた瞬間だけ広がる縁です。09は戻りも一度にはせず、76%でアイコン、84%で色とラベルに分けて断ちました。戻る変化が起きる変化より小さくないと、代表画像が狙いと違う側に寄ってしまいます。三つ目は、眺めているだけでは絶対に気づけない落とし穴でした。02のループはダミーの入力文字を steps(8, end) で出しますが、幅を108px にしたところ最後の一文字が半分で切れ、[email protected] と読めてしまいました。120px に広げて初めてアドレスが丸ごと出ました。一周ではなく回り続ける合図が要るならCSS ローディング スピナーがdiv一つでその役を縮めます。九つ分のソースが入った圧縮ファイルのパスワードは sqph35dy で、ここで直した値がそのまま入っています。
四つ目は大きさです。320×200の舞台では余白を差し引くと縦に使えるのは174px だけで、07の一覧は絶対配置なので親の高さ計算に入りません。見た目は問題なくても文書の高さだけが伸びて格子のマスにスクロールバーが出るため、包む箱に一覧の高さ分の padding-bottom をあらかじめ空けてあります。狭い画面ではその余白を116px から100px へ減らします。狭い側では間隔を切り上げず、切り捨てるのが決まりです。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)が有効になっていると、九つとも自動再生だけを止め、いまの状態はそのまま残します。01のスピナーは回転を止め、05の進捗は空のまま止まり、06の輪は育たずに一度で満ちます。状態を伝える属性はどちらの経路でも切れません。
| 項目 | マウス | キーボード | 状態が書かれる場所 |
|---|---|---|---|
| 01 非同期の一巡 | クリック | Enter · Space | aria-busy · disabled |
| 02 条件の錠 | クリック | Tab でフォーカス維持 | aria-disabled · aria-describedby |
| 03 トグル | クリック | Enter · Space | aria-pressed |
| 04 いいね | クリック | Enter · Space | aria-pressed · aria-live |
| 05 進捗 | クリック | Enter · Space | role="progressbar" · aria-valuenow |
| 06 長押し | 押したまま | Enter · Space | role="status" |
| 07 スプリット | 左右を別々に | Tab · Escape | aria-expanded · aria-haspopup |
| 08 セグメント | クリック | ← → キー | role="radio" · aria-checked |
| 09 コピー | クリック | Enter · Space | aria-live="polite" |
押下状態をクラスではなく属性に書く理由は MDN の aria-pressed の解説にまとまっています。05が使うカスタムプロパティの登録も MDN の @property の解説に文法と補間の条件が書かれていて、syntax を落とすと登録そのものが無視されます。手で押して始まる部品はクリックのカテゴリに、画面で一つだけ押してほしいボタンは CTA のカテゴリに集めてあります。
FAQ
押せないボタンには disabled と aria-disabled のどちらを使いますか
伝える理由があるなら aria-disabled、ないなら disabled です。本物の disabled はフォーカスを受け取れないのでキーボードがそのボタンに届かず、aria-describedby で結んだ理由も読み上げられません。ただし aria-disabled ではブラウザがクリックを止めてくれないので、押されても何もしないコードを自分で書く必要があります。押し込み自体を一行に残したいならCSS タップ エフェクトが短い版を持っています。
css ボタン クリック の完了表示はどれくらい出しておくのが適切ですか
2秒あれば十分です。01は応答を1.2秒に置き、完了を2秒見せてから戻しますが、これより短いと結果を見落とし、長いと次の操作が妨げられているように感じます。応答時間が読めない処理では、完了への切り替えは応答そのものに合わせ、表示時間だけを固定にするのが安全です。
九つを一つのプロジェクトに同居させても大丈夫ですか
項目ごとにクラスの接頭辞が違うので衝突しません。ただし05の @property --p は文書全体に一度だけ登録される名前なので、別の場所で同じ名前を違う syntax で登録し直すと後勝ちになります。進捗ボタンを複数使う予定があるなら、--dl-p のように部品名を付けた名前に変えておくほうが安全です。









