9 button state css patterns, loading to done
A button state css pattern decides what a button looks like between the press and the result.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 One round trip from loading to done
- 02 Button that unlocks when the form is valid
- 03 One button holding on and off
- 04 Like button with a rolling count
- 05 The button becomes its own progress bar
- 06 Delete that only fires on a long press
- 07 Primary action with other routes attached
- 08 Pill that slides to the chosen slot
- 09 Button that says copied for a moment
These nine are not ranked by popularity; they follow the order one button lives through. It gets pressed, waits for an answer, and returns (01); it explains why it cannot be pressed yet (02); it learns to stay pressed (03). The middle three are presses whose result stays on screen: as a number (04), as a few seconds of progress (05), and as something irreversible that asks you to confirm first (06). The last three are buttons that don't stand alone: one has another route bolted on beside it (07), one becomes one of three mutually exclusive slots (08), and one says what happened for a moment, then stops saying it (09). One rule runs through all nine. The current state lives in exactly one place — aria-busy, aria-pressed, aria-disabled, aria-checked, or aria-expanded, whichever fits — and the CSS reads that attribute. Keep it in two places and they will drift apart, and the half that drifts is always the half nobody can see. A button hover state css rule can only report that the pointer is nearby, so everything that happens after the press needs a state of its own. Picking the dressing before the press is what button hover effects css covers, and buttons stripped down to icons split off into Icon Button CSS Patterns.
01One round trip from loading to done
What you see
One press swaps the label for a spinner beside a "Saving" line, cuts to a check 1.2 seconds later, and returns to the original label two seconds after that. While it runs, the button is disabled, so it can't fire twice, and aria-busy says the work is in flight.

<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">Save</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">Saving</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">Saved</span>
</span>
</button>
<p class="ac__log" role="status" aria-live="polite">
<span class="ac__word" data-word="idle">Ready</span>
<span class="ac__word" data-word="busy">Sending</span>
<span class="ac__word" data-word="done">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 · One round trip from loading to done — Measured area 10.988% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A single function changes the state name, the ARIA flag, and the disabled flag together, so the visuals and the screen reader can never disagree.
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);
});
02Button that unlocks when the form is valid
How button disabled state css looks on screen
Until the field holds a real address, the button sits dimmed and locked, but because it uses aria-disabled instead of disabled, it still takes keyboard focus. Press it anyway and nothing happens except a small tooltip that says why, which stays for 1.4 seconds.

<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="Email address">
<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">Sign up</button>
<p class="eg__tip" id="eg-why" role="note">Needs a valid email</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 · Button that unlocks when the form is valid — Measured area 15.819% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A truly disabled button can't be focused at all, so there is never a moment when that explanation could be read out.
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');
});
03One button holding on and off
What moves
Two faces sit stacked in the same spot inside the button, and a selector that reads aria-pressed decides which one shows. The script does exactly one thing — flip that attribute — so the fill, the icon, and the announced state all come from the same value.

<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">Get alerts</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">Alerts on</span>
</span>
</button>
<p class="tp__note">One button holds both states</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 · One button holding on and off — Measured area 10.316% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
To make the bounce replay on back-to-back presses, remove the class and force a reflow before adding it again.
.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); }
}
04Like button with a rolling count
What you see
The number window is pinned to the height of a single line, with the old and new values stacked inside it. A like doesn't rewrite textContent; it lifts the stacked column by 22px so the new value arrives from below.

<main class="stage">
<div class="lk is-demo">
<button class="lk__btn" id="lk-btn" type="button" aria-pressed="false" aria-label="Like this post">
<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">The count rolls instead of swapping</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 · Like button with a rolling count — Measured area 1.325% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Digits have to keep a fixed width or the window jitters, which is what font-variant-numeric: tabular-nums is for.
.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); }
05The button becomes its own progress bar
What happens on screen
The percentage lives in one variable, and both the fill width and the printed number read it. Write it as a plain --p: 0 and the browser treats the value as text, so it jumps instead of flowing from 0 to 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">Download</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">Downloaded</span>
</span>
</button>
<p class="dl__note">The button itself is the progress bar</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 · The button becomes its own progress bar — Measured area 9.269% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Register it as an integer with @property and it interpolates; feed the same variable to counter-reset and it prints.
@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;
}
06Delete that only fires on a long press
What moves
Hold the button and the ring around it draws one lap through stroke-dashoffset; only a finished lap deletes anything. Let go early and the same transition rewinds, so nothing happens.

<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="Hold to delete">
<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">Hold to delete</span>
<span class="hd__word" data-word="done">Deleted</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 · Delete that only fires on a long press — Measured area 4.224% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Without setPointerCapture on the press, sliding a finger off the button never delivers the cancel event, and the ring keeps filling on its own.
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'); });
});
07Primary action with other routes attached
What you see
It looks like one control, but there are two button elements inside one outline, and only the right-hand arrow half opens the list. The wide left half runs the most common action immediately and only ever closes the list, so it never sets aria-expanded to true.

<main class="stage">
<div class="sp is-demo">
<div class="sp__wrap">
<div class="sp__group" role="group" aria-label="Send options">
<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">Send now</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="More ways to send">
<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">Schedule send</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">Save to drafts</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">Preview first</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 · Primary action with other routes attached — Measured area 24.579% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The list itself is a role="menu" whose rows are role="menuitem", which is what keeps the same path reachable from the keyboard.
<div class="sp__group" role="group" aria-label="Send options">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__t">Send now</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="More ways to send">
<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">Schedule send</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">Save to drafts</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">Preview first</button></li>
</ul>
08Pill that slides to the chosen slot
What happens on screen
The three slots are flex: 0 0 auto and pinned to 80px, and the pill only ever moves by multiples of that number. Mix a fixed travel distance with flex: 1 1 0 and the rendered slot width no longer matches the distance you hard-coded, which lands the pill halfway onto the next label.

<main class="stage">
<div class="sg is-demo">
<div class="sg__bar" role="radiogroup" aria-label="Pick a billing cycle" 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">Monthly</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="1">Yearly</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="2">Lifetime</button>
</div>
<p class="sg__note">Pin the slot width or the pill drifts</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 · Pill that slides to the chosen slot — Measured area 5.811% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The chosen slot is recorded in aria-checked, and the text color is read from that same attribute.
.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); }
09Button that says copied for a moment
What moves
The label changes only once navigator.clipboard.writeText has settled, and it slips back 1.6 seconds later. Somewhere the permission will be blocked, and the user still deserves a reaction, so success and failure both run the same closing function.

<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="Copy the command to the clipboard">
<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">Copy</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">Copied</span>
</span>
</button>
</div>
<p class="cp__live" role="status" aria-live="polite">
<span class="cp__word">Copied the command</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 · Button that says copied for a moment — Measured area 4.763% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
A label that changes for a moment is visual only, which is why the same words also go out through a role="status" line.
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();
}
});
Where does it break?
Components that change state break at the poster image first. Every demo is sampled at 24 frames and the one that differs most from the frame before it becomes the poster, and the first cut of 07 opened its menu in the loop and then closed it again. A menu leaving the screen always changes more pixels than a menu arriving, so the poster came out as an orange field with a closed button sitting in it. The fix was to stop closing the menu in the loop at all: open it once at 8%, leave it open, drop the rows in with a staggered translateY, then walk a background color down them. That reads as 24.58% changed area over 12 of 23 frames, and the poster is now the open list.
The second trap is a fight between cuts and frame count. Cutting a state change with steps(1, end) kills the half-transparent ghost frame, but a cut only ever produces two frames of difference. So every one of the nine carries a small transform-driven motion alongside its cut: a spinner that keeps turning in 01, a scale bounce in 03, a ring that expands only at the moment of copying in 09. Item 09 also splits its return, cutting the icon at 76% and the color and label at 84%. The return has to change less than the arrival, or the poster lands on the wrong half of the story. The third trap is one that no amount of staring would catch. The loop in 02 reveals a fake typed address with steps(8, end), and at a width of 108px the last character was sliced in half and read [email protected]. Only at 120px did the address come out whole. When the wait needs to spin instead of resolve, CSS Loading Spinners shrinks that job to a single div. The password for the archive with all nine sources is sqph35dy, and the numbers fixed here are the ones inside it.
The fourth trap is size. A 320×200 stage leaves 174px of vertical room once padding is gone, and the menu in 07 is absolutely positioned, so it never counts toward its parent's height. The stage looks fine while the document height quietly grows and the grid cell sprouts a scrollbar, which is why the wrapper reserves padding-bottom equal to the height of the open list. On narrow screens that reservation drops from 116px to 100px. Values inside a narrow-screen media query get rounded down, never up.
Accessibility and reduced-motion
With prefers-reduced-motion: reduce on, all nine stop the autoplay loop and leave the current state exactly where it is. The spinner in 01 stops turning, the fill in 05 stands empty, and the ring in 06 arrives full in one step instead of growing. Nothing in the attribute layer is switched off on either path.
| Item | Mouse | Keyboard | Where the state is written |
|---|---|---|---|
| 01 Async cycle | Click | Enter · Space | aria-busy · disabled |
| 02 Enable gate | Click | Tab keeps focus | aria-disabled · aria-describedby |
| 03 Toggle | Click | Enter · Space | aria-pressed |
| 04 Like | Click | Enter · Space | aria-pressed · aria-live |
| 05 Progress | Click | Enter · Space | role="progressbar" · aria-valuenow |
| 06 Hold | Press and hold | Enter · Space | role="status" |
| 07 Split | Click either half | Tab · Escape | aria-expanded · aria-haspopup |
| 08 Segments | Click | ← → arrows | role="radio" · aria-checked |
| 09 Copy | Click | Enter · Space | aria-live="polite" |
Why a pressed state belongs in an attribute rather than a class is spelled out in the MDN page on aria-pressed. The registration that 05 depends on is documented in the MDN page on @property, including the detail that leaving out syntax throws the whole registration away. Components that start because someone pressed them live in the click category, and buttons meant to be the one thing pressed on a page live in the CTA category.
FAQ
Should a locked button use disabled or aria-disabled?
Use aria-disabled when you have a reason to give, and disabled when you don't. A real disabled button can't receive focus, so the keyboard never reaches it and the explanation wired up through aria-describedby is never announced. The trade-off is that aria-disabled doesn't stop the click for you, so the handler has to return early on its own. Keeping just the press itself in one line is the short version CSS Button Click Feedback Effects carries.
How long should a css button click effect hold its finished state?
Two seconds is enough. Item 01 answers in 1.2 seconds, holds the check for two, and then returns; any shorter and the result gets missed, any longer and the next action feels blocked. When the response time is unknown, switch to the finished state the moment the response lands and put only the finished display on a fixed timer.
Can all nine live in one project?
They can, because each item carries its own class prefix. The one shared name is @property --p in 05, which is registered document-wide, so a second registration elsewhere with a different syntax wins simply by coming later. If you're planning several progress buttons, rename it to something like --dl-p so the registration belongs to the component.









