css 버튼 클릭 효과 9종 — 누른 다음이 진짜다
css 버튼 클릭 효과는 누른 순간부터 결과가 나올 때까지 버튼이 스스로 모습을 바꿔 알려 주는 방식입니다. 아홉 개 모두 색만 바뀌는 흉내가 아니라 실제로 잠기고 돌고 되돌아오며, 그 사실을 ARIA 속성 한 곳에 적습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 로딩에서 완료로 도는 한 바퀴
- 02 조건이 차야 열리는 버튼
- 03 켜짐과 꺼짐을 한 버튼이 쥔다
- 04 숫자가 굴러 올라가는 좋아요
- 05 버튼 자신이 진행 막대가 된다
- 06 길게 눌러야 지워지는 삭제
- 07 주 동작과 다른 길이 붙은 버튼
- 08 고른 칸을 따라 미끄러지는 알약
- 09 복사했다고 잠깐만 말하는 버튼
순서는 인기순이 아니라 버튼 하나가 겪는 순서입니다. 먼저 눌린 뒤 결과를 기다렸다 돌아오고(01), 아직 누를 수 없는 이유를 밝히고(02), 눌린 채로 머무는 법을 배웁니다(03). 가운데 셋은 누른 결과가 화면에 남는 경우입니다. 숫자로 남고(04), 몇 초짜리 진행으로 남고(05), 되돌릴 수 없어서 한 번 더 확인받습니다(06). 마지막 셋은 버튼이 혼자가 아닐 때입니다. 옆에 다른 길이 붙고(07), 서로 배타적인 셋 중 하나가 되고(08), 눌렀다는 사실을 잠깐만 말하고 사라집니다(09). 아홉 개가 공유하는 원칙은 하나입니다. 지금 상태는 클래스가 아니라 aria-busy·aria-pressed·aria-disabled·aria-checked·aria-expanded 가운데 그 상황에 맞는 속성 한 곳에만 적고, 보이는 색은 그 속성을 읽어서 정합니다. 두 곳에 적으면 언젠가 반드시 어긋나고, 어긋난 쪽은 늘 눈에 보이지 않는 쪽입니다. css 버튼 눌림 효과 하나만 떼어 놓고 봐도 눌리는 순간과 눌려 있는 동안과 손을 뗀 뒤가 서로 다른 상태라, 색 하나로는 셋을 구분해 주지 못합니다. 누르기 전의 꾸밈을 고르는 일은 css 버튼 hover 효과 모음이 맡고, 아이콘만 남은 버튼의 판은 플로팅 버튼 아이콘 버튼 패턴으로 갈라집니다. 상태가 갈리는 컷 수는 세 장을 넘기지 않았습니다. 01번의 로딩·완료 사이에 건너뛸 칸이 많아지면 누른 사람은 결과가 아니라 중간 과정을 기억하게 되므로, 실패 판은 아이콘 색만 바꾸고 컷 수는 그대로 두었습니다.
01로딩에서 완료로 도는 한 바퀴
무엇이 보이나
한 번 누르면 라벨이 스피너 달린 '저장 중'으로 바뀌고, 1.2초 뒤 체크 표시로 끊겼다가, 다시 2초 뒤 원래 라벨로 돌아옵니다. 도는 동안 버튼은 disabled 라서 두 번 눌리지 않고 aria-busy 가 진행 중임을 알립니다.

<main class="stage">
<div class="ac is-demo">
<button class="ac__btn" id="ac-btn" type="button" data-state="idle" aria-busy="false">
<span class="ac__face" data-face="idle">
<span class="ac__t">저장하기</span>
</span>
<span class="ac__face" data-face="busy">
<span class="ac__ic ac__ic--spin" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M136 32v32a8 8 0 0 1-16 0V32a8 8 0 0 1 16 0m88 88h-32a8 8 0 0 0 0 16h32a8 8 0 0 0 0-16m-45.09 47.6a8 8 0 0 0-11.31 11.31l22.62 22.63a8 8 0 0 0 11.32-11.32ZM128 184a8 8 0 0 0-8 8v32a8 8 0 0 0 16 0v-32a8 8 0 0 0-8-8m-50.91-16.4l-22.63 22.62a8 8 0 0 0 11.32 11.32l22.62-22.63a8 8 0 0 0-11.31-11.31M72 128a8 8 0 0 0-8-8H32a8 8 0 0 0 0 16h32a8 8 0 0 0 8-8m-6.22-73.54a8 8 0 0 0-11.32 11.32L77.09 88.4A8 8 0 0 0 88.4 77.09Z"/></g></svg></span>
<span class="ac__t">저장 중</span>
</span>
<span class="ac__face" data-face="done">
<span class="ac__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="ac__t">저장했습니다</span>
</span>
</button>
<p class="ac__log" role="status" aria-live="polite">
<span class="ac__word" data-word="idle">누를 수 있습니다</span>
<span class="ac__word" data-word="busy">보내는 중입니다</span>
<span class="ac__word" data-word="done">끝났습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.ac {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ac__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
box-shadow: $shadow-raised;
}
.ac__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.ac__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.ac__ic { display: inline-flex; flex: 0 0 auto; }
.ac__ic svg { display: block; }
.ac__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 사람이 실제로 누른 경로 — 루프를 껐을 때도 화면이 비지 않게 정적 규칙을 먼저 둔다
.ac:not(.is-demo) .ac__btn[data-state="idle"] .ac__face[data-face="idle"],
.ac:not(.is-demo) .ac__btn[data-state="busy"] .ac__face[data-face="busy"],
.ac:not(.is-demo) .ac__btn[data-state="done"] .ac__face[data-face="done"] { opacity: 1; }
.ac:not(.is-demo) .ac__btn[data-state="busy"] { background-color: $subject-blue; color: $subject-cream; }
.ac:not(.is-demo) .ac__btn[data-state="done"] { background-color: $color; color: $subject-ink; }
.ac__log {
position: relative;
margin: 0;
width: 100%;
height: 18px;
}
.ac__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
opacity: 0;
}
.ac:not(.is-demo) .ac__btn[data-state="idle"] ~ .ac__log .ac__word[data-word="idle"],
.ac:not(.is-demo) .ac__btn[data-state="busy"] ~ .ac__log .ac__word[data-word="busy"],
.ac:not(.is-demo) .ac__btn[data-state="done"] ~ .ac__log .ac__word[data-word="done"] { opacity: 1; }
// 스피너는 도는 동안 매 프레임 달라진다 — 컷 두 번만으로는 움직인 프레임이 안 찬다
.ac__ic--spin svg {
animation-name: ac-spin;
animation-duration: 800ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ac-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// ── 자동 루프: idle → busy(20%) → done(60%) → idle(88%). 상태 교체는 한 프레임에 끊는다 ──
.ac.is-demo .ac__face,
.ac.is-demo .ac__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__face[data-face="idle"],
.ac.is-demo .ac__word[data-word="idle"] { animation-name: ac-idle; }
.ac.is-demo .ac__face[data-face="busy"],
.ac.is-demo .ac__word[data-word="busy"] { animation-name: ac-busy; }
.ac.is-demo .ac__face[data-face="done"],
.ac.is-demo .ac__word[data-word="done"] { animation-name: ac-done; }
@keyframes ac-idle {
0% { opacity: 1; }
20% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ac-busy {
0% { opacity: 0; }
20% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ac-done {
0% { opacity: 0; }
60% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 껍질 색은 컷으로, 눌림 반동은 부드럽게 — 두 애니메이션을 한 요소에 겹쳐 건다
.ac.is-demo .ac__btn {
animation-name: ac-skin, ac-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes ac-skin {
0% { background-color: $subject-cream; color: $subject-ink; }
20% { background-color: $subject-blue; color: $subject-cream; }
60% { background-color: $color; color: $subject-ink; }
88% { background-color: $subject-cream; color: $subject-ink; }
100% { background-color: $subject-cream; color: $subject-ink; }
}
// 완료로 갈리는 순간 버튼이 한 번 눌렸다 펴진다 — 컷만으로는 대표 장면이 밋밋하다
@keyframes ac-pop {
0% { transform: scale(1); }
18% { transform: scale(1); }
22% { transform: scale(.96); }
30% { transform: scale(1); }
58% { transform: scale(1); }
63% { transform: scale(1.04); }
72% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.ac { gap: $sp-2; }
.ac__btn { height: 44px; }
.ac__t { font-size: 13px; line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__face,
.ac.is-demo .ac__word,
.ac.is-demo .ac__btn,
.ac__ic--spin svg { animation: none !important; }
.ac.is-demo .ac__face[data-face="idle"],
.ac.is-demo .ac__word[data-word="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ac__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ac__btn:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 3px;
}
.ac__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.ac__ic {
display: inline-flex;
flex: 0 0 auto;
}
.ac__ic svg {
display: block;
}
.ac__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.ac:not(.is-demo) .ac__btn[data-state=idle] .ac__face[data-face=idle],
.ac:not(.is-demo) .ac__btn[data-state=busy] .ac__face[data-face=busy],
.ac:not(.is-demo) .ac__btn[data-state=done] .ac__face[data-face=done] {
opacity: 1;
}
.ac:not(.is-demo) .ac__btn[data-state=busy] {
background-color: #2f6df6;
color: #fff7e6;
}
.ac:not(.is-demo) .ac__btn[data-state=done] {
background-color: #4cd4a6;
color: #17141a;
}
.ac__log {
position: relative;
margin: 0;
width: 100%;
height: 18px;
}
.ac__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
opacity: 0;
}
.ac:not(.is-demo) .ac__btn[data-state=idle] ~ .ac__log .ac__word[data-word=idle],
.ac:not(.is-demo) .ac__btn[data-state=busy] ~ .ac__log .ac__word[data-word=busy],
.ac:not(.is-demo) .ac__btn[data-state=done] ~ .ac__log .ac__word[data-word=done] {
opacity: 1;
}
.ac__ic--spin svg {
animation-name: ac-spin;
animation-duration: 800ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ac-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ac.is-demo .ac__face,
.ac.is-demo .ac__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__face[data-face=idle],
.ac.is-demo .ac__word[data-word=idle] {
animation-name: ac-idle;
}
.ac.is-demo .ac__face[data-face=busy],
.ac.is-demo .ac__word[data-word=busy] {
animation-name: ac-busy;
}
.ac.is-demo .ac__face[data-face=done],
.ac.is-demo .ac__word[data-word=done] {
animation-name: ac-done;
}
@keyframes ac-idle {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ac-busy {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-done {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ac.is-demo .ac__btn {
animation-name: ac-skin, ac-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes ac-skin {
0% {
background-color: #fff7e6;
color: #17141a;
}
20% {
background-color: #2f6df6;
color: #fff7e6;
}
60% {
background-color: #4cd4a6;
color: #17141a;
}
88% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes ac-pop {
0% {
transform: scale(1);
}
18% {
transform: scale(1);
}
22% {
transform: scale(0.96);
}
30% {
transform: scale(1);
}
58% {
transform: scale(1);
}
63% {
transform: scale(1.04);
}
72% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.ac {
gap: 8px;
}
.ac__btn {
height: 44px;
}
.ac__t {
font-size: 13px;
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__face,
.ac.is-demo .ac__word,
.ac.is-demo .ac__btn,
.ac__ic--spin svg {
animation: none !important;
}
.ac.is-demo .ac__face[data-face=idle],
.ac.is-demo .ac__word[data-word=idle] {
opacity: 1;
}
}// 실제 동작: 한 번 누르면 busy → done → idle 로 스스로 돌아온다. 도는 동안 버튼은 잠긴다
var root = document.querySelector('.ac');
var btn = document.getElementById('ac-btn');
var t1 = 0, t2 = 0;
function setState(s) {
btn.setAttribute('data-state', s);
btn.setAttribute('aria-busy', String(s === 'busy'));
btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimeout(t1);
clearTimeout(t2);
setState('busy');
t1 = setTimeout(function () { setState('done'); }, 1200); // 서버가 답한 시점
t2 = setTimeout(function () { setState('idle'); }, 3200); // 완료 표시를 2초 보여 주고 원복
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./AsyncCycleButton.module.scss";
export type AsyncState = "idle" | "busy" | "done";
export type AsyncCycleButtonProps = {
labels?: Record<AsyncState, string>;
logs?: Record<AsyncState, string>;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Timer = ReturnType<typeof setTimeout>;
const BUSY_MS = 1200; // 서버가 답한 시점
const DONE_MS = 2000; // 완료 표시를 2초 보여 주고 원복
const SPINNER = (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M136 32v32a8 8 0 0 1-16 0V32a8 8 0 0 1 16 0m88 88h-32a8 8 0 0 0 0 16h32a8 8 0 0 0 0-16m-45.09 47.6a8 8 0 0 0-11.31 11.31l22.62 22.63a8 8 0 0 0 11.32-11.32ZM128 184a8 8 0 0 0-8 8v32a8 8 0 0 0 16 0v-32a8 8 0 0 0-8-8m-50.91-16.4l-22.63 22.62a8 8 0 0 0 11.32 11.32l22.62-22.63a8 8 0 0 0-11.31-11.31M72 128a8 8 0 0 0-8-8H32a8 8 0 0 0 0 16h32a8 8 0 0 0 8-8m-6.22-73.54a8 8 0 0 0-11.32 11.32L77.09 88.4A8 8 0 0 0 88.4 77.09Z" />
</g>
);
const CHECK = (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
);
export default function AsyncCycleButton({
labels = { idle: "Save", busy: "Saving", done: "Saved" },
logs = { idle: "Ready to press", busy: "Sending now", done: "All done" },
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: AsyncCycleButtonProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<AsyncState>("idle");
const t1 = useRef<Timer>(null);
const t2 = useRef<Timer>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function clear() {
if (t1.current !== null) clearTimeout(t1.current);
if (t2.current !== null) clearTimeout(t2.current);
}
useEffect(() => clear, []); // 떠날 때 예약된 타이머를 버린다
// 한 번 누르면 busy → done → idle 로 스스로 돌아온다. 도는 동안 버튼은 잠긴다
function run() {
setDemo(false); // 사람이 눌렀으니 자동 루프를 끈다
clear();
setState("busy");
t1.current = setTimeout(() => setState("done"), BUSY_MS);
t2.current = setTimeout(() => setState("idle"), BUSY_MS + DONE_MS);
}
return (
<div className={`${styles.ac} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={styles.btn}
type="button"
data-state={state}
aria-busy={state === "busy"}
disabled={state !== "idle"}
onClick={run}
>
<span className={styles.face} data-face="idle">
<span className={styles.txt}>{labels.idle}</span>
</span>
<span className={styles.face} data-face="busy">
<span className={`${styles.ic} ${styles.icSpin}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{SPINNER}</svg>
</span>
<span className={styles.txt}>{labels.busy}</span>
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{CHECK}</svg>
</span>
<span className={styles.txt}>{labels.done}</span>
</span>
</button>
<p className={styles.log} role="status" aria-live="polite">
<span className={styles.word} data-word="idle">{logs.idle}</span>
<span className={styles.word} data-word="busy">{logs.busy}</span>
<span className={styles.word} data-word="done">{logs.done}</span>
</p>
</div>
);
}01 · 로딩에서 완료로 도는 한 바퀴 — 측정 누적면적 10.988% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
상태 이름 하나를 바꾸는 함수가 속성 셋을 한꺼번에 갱신하므로 화면과 보조기기가 갈라질 틈이 없습니다.
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초 보여 주고 원복
});
02조건이 차야 열리는 버튼
버튼 비활성화 css 는 화면에서 어떻게 보이나
입력칸이 이메일 형식을 갖추기 전에는 버튼이 흐리게 잠겨 있지만, disabled 대신 aria-disabled 를 쓰기 때문에 초점은 그대로 받습니다. 잠긴 채로 누르면 아무 일도 일어나지 않는 대신, 왜 안 되는지가 말풍선으로 1.4초 동안 붙습니다.

<main class="stage">
<div class="eg is-demo">
<div class="eg__field">
<input class="eg__input" id="eg-mail" type="email" required placeholder="[email protected]" aria-label="이메일 주소">
<span class="eg__ghost" aria-hidden="true">[email protected]</span>
</div>
<div class="eg__wrap">
<button class="eg__btn" id="eg-btn" type="button" aria-disabled="true" aria-describedby="eg-why">가입하기</button>
<p class="eg__tip" id="eg-why" role="note">이메일 형식이 맞아야 눌립니다</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.eg {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.eg__field {
position: relative;
height: 40px;
}
.eg__input {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
width: 100%;
height: 100%;
border: 2px solid $color;
border-radius: $r-sm;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.eg__input:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 루프에서만 쓰는 가짜 입력 글자 — 진짜 입력칸 위에 겹쳐 한 글자씩 드러난다
.eg__ghost {
position: absolute;
left: 14px;
top: 10px;
overflow: hidden;
white-space: nowrap;
width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: $color;
}
.eg:not(.is-demo) .eg__ghost { display: none; }
.eg.is-demo .eg__input::placeholder { color: transparent; }
.eg__wrap {
position: relative;
padding-bottom: $sp-8; // 절대 배치한 말풍선 높이만큼 자리를 미리 비워 둔다 (scrollHeight 넘침 방지)
}
.eg__btn {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-4;
width: 100%;
height: 44px;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: 15px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.eg__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.eg__btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
.eg__btn[aria-disabled="false"] { background-color: $color; color: $stage-yellow; box-shadow: $shadow-raised; }
.eg__btn[data-sent="1"] { background-color: $subject-mint; color: $color; }
.eg__tip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background-color: $color;
color: $subject-cream;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 500;
pointer-events: none; // 말풍선이 버튼의 호버·클릭을 가로채면 안 된다
opacity: 0;
}
.eg:not(.is-demo) .eg__btn[aria-disabled="true"]:hover + .eg__tip,
.eg:not(.is-demo) .eg__btn:focus-visible + .eg__tip,
.eg:not(.is-demo) .eg__btn[data-why="1"] + .eg__tip { opacity: 1; }
// ── 자동 루프: 잠김 → 한 글자씩 입력(20~46%) → 열림(50%) → 다시 잠김(86%) ──
.eg.is-demo .eg__ghost {
animation-name: eg-type;
animation-duration: $dur-loop;
animation-timing-function: steps(8, end);
animation-iteration-count: infinite;
}
@keyframes eg-type {
0% { width: 0; }
20% { width: 0; }
46% { width: 120px; }
86% { width: 120px; }
87% { width: 0; }
100% { width: 0; }
}
.eg.is-demo .eg__btn {
animation-name: eg-open;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-open {
0% { opacity: .45; background-color: $subject-cream; color: $color; }
50% { opacity: 1; background-color: $color; color: $stage-yellow; }
86% { opacity: .45; background-color: $subject-cream; color: $color; }
100% { opacity: .45; background-color: $subject-cream; color: $color; }
}
.eg.is-demo .eg__tip {
animation-name: eg-tip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-tip {
0% { opacity: 1; }
50% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
// 열리는 순간 버튼이 한 번 솟는다 — 컷 두 번만으로는 움직인 프레임이 모자란다
.eg.is-demo .eg__wrap {
animation-name: eg-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes eg-rise {
0% { transform: translateY(0); }
48% { transform: translateY(0); }
54% { transform: translateY(-6px); }
66% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.eg { gap: $sp-2; }
.eg__field { height: 36px; }
.eg__btn { height: 40px; font-size: 13px; line-height: 18px; }
.eg__ghost { top: 8px; }
.eg__wrap { padding-bottom: $sp-6; }
}
@media (prefers-reduced-motion: reduce) {
.eg.is-demo .eg__ghost,
.eg.is-demo .eg__btn,
.eg.is-demo .eg__tip,
.eg.is-demo .eg__wrap { animation: none !important; }
.eg.is-demo .eg__tip { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.eg {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.eg__field {
position: relative;
height: 40px;
}
.eg__input {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
width: 100%;
height: 100%;
border: 2px solid #17141a;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.eg__input:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.eg__ghost {
position: absolute;
left: 14px;
top: 10px;
overflow: hidden;
white-space: nowrap;
width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: #17141a;
}
.eg:not(.is-demo) .eg__ghost {
display: none;
}
.eg.is-demo .eg__input::placeholder {
color: transparent;
}
.eg__wrap {
position: relative;
padding-bottom: 32px;
}
.eg__btn {
box-sizing: border-box;
margin: 0;
padding: 0 16px;
width: 100%;
height: 44px;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 15px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.eg__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.eg__btn[aria-disabled=true] {
opacity: 0.45;
cursor: not-allowed;
}
.eg__btn[aria-disabled=false] {
background-color: #17141a;
color: #ffd23f;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.eg__btn[data-sent="1"] {
background-color: #4cd4a6;
color: #17141a;
}
.eg__tip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 4px 8px;
border-radius: 4px;
background-color: #17141a;
color: #fff7e6;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 500;
pointer-events: none;
opacity: 0;
}
.eg:not(.is-demo) .eg__btn[aria-disabled=true]:hover + .eg__tip,
.eg:not(.is-demo) .eg__btn:focus-visible + .eg__tip,
.eg:not(.is-demo) .eg__btn[data-why="1"] + .eg__tip {
opacity: 1;
}
.eg.is-demo .eg__ghost {
animation-name: eg-type;
animation-duration: 2s;
animation-timing-function: steps(8, end);
animation-iteration-count: infinite;
}
@keyframes eg-type {
0% {
width: 0;
}
20% {
width: 0;
}
46% {
width: 120px;
}
86% {
width: 120px;
}
87% {
width: 0;
}
100% {
width: 0;
}
}
.eg.is-demo .eg__btn {
animation-name: eg-open;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-open {
0% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
50% {
opacity: 1;
background-color: #17141a;
color: #ffd23f;
}
86% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
100% {
opacity: 0.45;
background-color: #fff7e6;
color: #17141a;
}
}
.eg.is-demo .eg__tip {
animation-name: eg-tip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes eg-tip {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.eg.is-demo .eg__wrap {
animation-name: eg-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes eg-rise {
0% {
transform: translateY(0);
}
48% {
transform: translateY(0);
}
54% {
transform: translateY(-6px);
}
66% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.eg {
gap: 8px;
}
.eg__field {
height: 36px;
}
.eg__btn {
height: 40px;
font-size: 13px;
line-height: 18px;
}
.eg__ghost {
top: 8px;
}
.eg__wrap {
padding-bottom: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.eg.is-demo .eg__ghost,
.eg.is-demo .eg__btn,
.eg.is-demo .eg__tip,
.eg.is-demo .eg__wrap {
animation: none !important;
}
.eg.is-demo .eg__tip {
opacity: 1;
}
}// 실제 동작: disabled 대신 aria-disabled 를 쓴다. 초점은 받되 동작만 막아, 왜 못 누르는지 읽힐 수 있다
var root = document.querySelector('.eg');
var mail = document.getElementById('eg-mail');
var btn = document.getElementById('eg-btn');
function validate() {
var ok = mail.checkValidity() && mail.value.length > 0;
btn.setAttribute('aria-disabled', String(!ok));
return ok;
}
mail.addEventListener('input', function () {
root.classList.remove('is-demo');
validate();
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!validate()) {
btn.setAttribute('data-why', '1'); // 잠긴 이유를 한 번 흔들어 보여 준다
setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
return;
}
btn.setAttribute('data-sent', '1');
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./EnableGateButton.module.scss";
export type EnableGateButtonProps = {
labels?: { submit: string; tip: string; placeholder: string; mail: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Timer = ReturnType<typeof setTimeout>;
type MailEvt = { currentTarget: { value: string; checkValidity(): boolean } };
const TIP_ID = "eg-why";
const WHY_MS = 1400; // 잠긴 이유를 보여 주는 시간
export default function EnableGateButton({
labels = {
submit: "Sign up",
tip: "Enter a valid email first",
placeholder: "[email protected]",
mail: "Email address",
},
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: EnableGateButtonProps) {
const [demo, setDemo] = useState(true);
const [ok, setOk] = useState(false);
const [why, setWhy] = useState(false);
const [sent, setSent] = useState(false);
const tw = useRef<Timer>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => () => {
if (tw.current !== null) clearTimeout(tw.current); // 떠날 때 예약된 타이머를 버린다
}, []);
// disabled 대신 aria-disabled 를 쓴다. 초점은 받되 동작만 막아, 왜 못 누르는지 읽힐 수 있다
function check(e: MailEvt) {
setDemo(false); // 사람이 입력했으니 자동 루프를 끈다
const el = e.currentTarget;
setOk(el.checkValidity() && el.value.length > 0);
}
function press() {
setDemo(false);
if (!ok) {
setWhy(true); // 잠긴 이유를 한 번 흔들어 보여 준다
if (tw.current !== null) clearTimeout(tw.current);
tw.current = setTimeout(() => setWhy(false), WHY_MS);
return;
}
setSent(true);
}
return (
<div className={`${styles.eg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.field}>
<input
className={styles.input}
type="email"
required
placeholder={labels.placeholder}
aria-label={labels.mail}
onChange={check}
/>
<span className={styles.ghost} aria-hidden="true">{labels.placeholder}</span>
</div>
<div className={styles.wrap}>
<button
className={styles.btn}
type="button"
aria-disabled={!ok}
aria-describedby={TIP_ID}
data-why={why ? "1" : undefined}
data-sent={sent ? "1" : undefined}
onClick={press}
>
{labels.submit}
</button>
<p className={styles.tip} id={TIP_ID} role="note">{labels.tip}</p>
</div>
</div>
);
}02 · 조건이 차야 열리는 버튼 — 측정 누적면적 15.819% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
진짜 disabled 였다면 그 버튼은 초점을 받지 못해 이유를 읽어 줄 방법 자체가 없습니다.
function validate() {
var ok = mail.checkValidity() && mail.value.length > 0;
btn.setAttribute('aria-disabled', String(!ok));
return ok;
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!validate()) {
btn.setAttribute('data-why', '1'); // 잠긴 이유를 한 번 흔들어 보여 준다
setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
return;
}
btn.setAttribute('data-sent', '1');
});
03켜짐과 꺼짐을 한 버튼이 쥔다
어떤 움직임인가
버튼 안에 얼굴 두 장이 같은 자리에 겹쳐 있고, 어느 쪽이 보일지는 aria-pressed 값만 읽는 선택자가 정합니다. JS 가 하는 일은 그 속성을 뒤집는 것 하나뿐이라 색과 아이콘, 읽히는 상태가 모두 같은 값에서 나옵니다.

<main class="stage">
<div class="tp is-demo">
<button class="tp__btn" id="tp-btn" type="button" aria-pressed="false">
<span class="tp__face" data-face="off">
<span class="tp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M53.92 34.62a8 8 0 1 0-11.84 10.76L58.82 63.8A79.6 79.6 0 0 0 48 104c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.8a40 40 0 0 0 78.4 0h15.44l19.44 21.38a8 8 0 1 0 11.84-10.76ZM128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a63.65 63.65 0 0 1 6.26-27.62L168.09 184Zm166-4.73a8.1 8.1 0 0 1-2.93.55a8 8 0 0 1-7.44-5.08C196.35 156.19 192 129.75 192 104a64 64 0 0 0-95.57-55.69a8 8 0 0 1-7.9-13.91A80 80 0 0 1 208 104c0 35.35 8.05 58.59 10.52 64.88a8 8 0 0 1-4.52 10.37Z"/></g></svg></span>
<span class="tp__lab">알림 받기</span>
</span>
<span class="tp__face" data-face="on">
<span class="tp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></span>
<span class="tp__lab">알림 받는 중</span>
</span>
</button>
<p class="tp__note">같은 버튼 하나가 두 상태를 쥡니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.tp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-pill;
background-color: $subject-cream;
color: $color;
cursor: pointer;
box-shadow: $shadow-press;
}
.tp__btn:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
.tp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.tp__ic { display: inline-flex; flex: 0 0 auto; }
.tp__ic svg { display: block; }
.tp__lab {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 눌림 여부는 aria-pressed 한 곳에만 적힌다 — 색도 아이콘도 그 값에서 갈린다
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% { transform: scale(.94); }
60% { transform: scale(1.03); }
100% { transform: scale(1); }
}
.tp__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 1초마다 켜짐과 꺼짐을 한 프레임에 끊어 바꾼다 ──
.tp.is-demo .tp__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tp.is-demo .tp__face[data-face="off"] { animation-name: tp-off; }
.tp.is-demo .tp__face[data-face="on"] { animation-name: tp-on; }
@keyframes tp-off {
0% { opacity: 1; }
40% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes tp-on {
0% { opacity: 0; }
40% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
.tp.is-demo .tp__btn {
animation-name: tp-skin, tp-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes tp-skin {
0% { background-color: $subject-cream; color: $color; }
40% { background-color: $color; color: $stage-yellow; }
86% { background-color: $subject-cream; color: $color; }
100% { background-color: $subject-cream; color: $color; }
}
// 갈리는 순간마다 알약이 한 번 눌렸다 펴진다 — 컷 두 번만으로는 움직인 프레임이 안 찬다
@keyframes tp-pop {
0% { transform: scale(1); }
38% { transform: scale(1); }
43% { transform: scale(.94); }
52% { transform: scale(1.03); }
60% { transform: scale(1); }
84% { transform: scale(1); }
89% { transform: scale(.96); }
96% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.tp { gap: $sp-2; }
.tp__btn { height: 44px; }
.tp__lab { font-size: 13px; line-height: 18px; }
.tp__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__face,
.tp.is-demo .tp__btn,
.tp__btn.is-pop { animation: none !important; }
.tp.is-demo .tp__face[data-face="off"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 999px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.tp__btn:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.tp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.tp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.tp__ic svg {
display: block;
}
.tp__lab {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.tp:not(.is-demo) .tp__btn[aria-pressed=false] .tp__face[data-face=off],
.tp:not(.is-demo) .tp__btn[aria-pressed=true] .tp__face[data-face=on] {
opacity: 1;
}
.tp:not(.is-demo) .tp__btn[aria-pressed=true] {
background-color: #17141a;
color: #ffd23f;
}
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% {
transform: scale(0.94);
}
60% {
transform: scale(1.03);
}
100% {
transform: scale(1);
}
}
.tp__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.tp.is-demo .tp__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tp.is-demo .tp__face[data-face=off] {
animation-name: tp-off;
}
.tp.is-demo .tp__face[data-face=on] {
animation-name: tp-on;
}
@keyframes tp-off {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes tp-on {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.tp.is-demo .tp__btn {
animation-name: tp-skin, tp-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes tp-skin {
0% {
background-color: #fff7e6;
color: #17141a;
}
40% {
background-color: #17141a;
color: #ffd23f;
}
86% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes tp-pop {
0% {
transform: scale(1);
}
38% {
transform: scale(1);
}
43% {
transform: scale(0.94);
}
52% {
transform: scale(1.03);
}
60% {
transform: scale(1);
}
84% {
transform: scale(1);
}
89% {
transform: scale(0.96);
}
96% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.tp {
gap: 8px;
}
.tp__btn {
height: 44px;
}
.tp__lab {
font-size: 13px;
line-height: 18px;
}
.tp__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__face,
.tp.is-demo .tp__btn,
.tp__btn.is-pop {
animation: none !important;
}
.tp.is-demo .tp__face[data-face=off] {
opacity: 1;
}
}// 실제 동작: 클래스가 아니라 aria-pressed 를 뒤집는다. 보이는 색과 읽히는 상태가 한 값에서 나온다
var root = document.querySelector('.tp');
var btn = document.getElementById('tp-btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
btn.classList.remove('is-pop');
void btn.offsetWidth; // 같은 방향으로 다시 눌러도 반동이 한 번 더 돌게 리플로를 강제한다
btn.classList.add('is-pop');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TogglePressedButton.module.scss";
export type TogglePressedButtonProps = {
labels?: { off: string; on: string; note: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BELL_OFF = (
<g fill="currentColor">
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M53.92 34.62a8 8 0 1 0-11.84 10.76L58.82 63.8A79.6 79.6 0 0 0 48 104c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.8a40 40 0 0 0 78.4 0h15.44l19.44 21.38a8 8 0 1 0 11.84-10.76ZM128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a63.65 63.65 0 0 1 6.26-27.62L168.09 184Zm166-4.73a8.1 8.1 0 0 1-2.93.55a8 8 0 0 1-7.44-5.08C196.35 156.19 192 129.75 192 104a64 64 0 0 0-95.57-55.69a8 8 0 0 1-7.9-13.91A80 80 0 0 1 208 104c0 35.35 8.05 58.59 10.52 64.88a8 8 0 0 1-4.52 10.37Z" />
</g>
);
const BELL_ON = (
<g fill="currentColor">
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" />
</g>
);
export default function TogglePressedButton({
labels = { off: "Get alerts", on: "Alerts on", note: "One button holds both states" },
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: TogglePressedButtonProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [pop, setPop] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 클래스가 아니라 aria-pressed 를 뒤집는다. 보이는 색과 읽히는 상태가 한 값에서 나온다
function toggle() {
setDemo(false); // 사람이 눌렀으니 자동 루프를 끈다
setOn((v) => !v);
// 같은 방향으로 다시 눌러도 반동이 한 번 더 돌게 한 프레임 뒤에 다시 건다
setPop(false);
requestAnimationFrame(() => setPop(true));
}
return (
<div className={`${styles.tp} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={`${styles.btn} ${pop ? styles.isPop : ""}`}
type="button"
aria-pressed={on}
onClick={toggle}
onAnimationEnd={() => setPop(false)}
>
<span className={styles.face} data-face="off">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{BELL_OFF}</svg>
</span>
<span className={styles.lab}>{labels.off}</span>
</span>
<span className={styles.face} data-face="on">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">{BELL_ON}</svg>
</span>
<span className={styles.lab}>{labels.on}</span>
</span>
</button>
<p className={styles.note}>{labels.note}</p>
</div>
);
}03 · 켜짐과 꺼짐을 한 버튼이 쥔다 — 측정 누적면적 10.316% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
같은 방향으로 다시 눌러도 반동이 돌게 하려면 클래스를 뗀 뒤 리플로를 한 번 강제해야 합니다.
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% { transform: scale(.94); }
60% { transform: scale(1.03); }
100% { transform: scale(1); }
}
04숫자가 굴러 올라가는 좋아요
무엇이 보이나
숫자 창은 높이를 한 줄로 못 박고 그 안에 옛 값과 새 값을 세로로 쌓아 둡니다. 좋아요를 누르면 textContent 를 바꾸는 대신 두 값이 든 줄을 22px 만큼 올려 새 값이 아래에서 올라오게 합니다.

<main class="stage">
<div class="lk is-demo">
<button class="lk__btn" id="lk-btn" type="button" aria-pressed="false" aria-label="좋아요 누르기">
<span class="lk__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
<span class="lk__win" aria-live="polite">
<span class="lk__roll">
<span class="lk__n">1,248</span>
<span class="lk__n">1,249</span>
</span>
</span>
</button>
<p class="lk__note">숫자가 갈아끼워지지 않고 굴러 올라갑니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-orange;
@include stage($stage-paper);
.lk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lk__btn {
box-sizing: border-box;
margin: 0;
padding: $sp-2 $sp-5;
display: inline-flex;
align-items: center;
gap: $sp-2;
height: 48px;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background-color: transparent;
color: $subject-ink;
cursor: pointer;
}
.lk__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 3px; }
.lk__heart { display: inline-flex; flex: 0 0 auto; }
.lk__heart svg { display: block; }
// 숫자 창 — 높이를 한 줄로 못 박고 그 안에서 두 값이 세로로 오간다
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll {
display: block;
transform: translateY(0);
}
.lk__n {
display: block;
height: 22px;
font-size: 15px;
line-height: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
// 사람이 실제로 누른 경로
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] { border-color: $color; color: $color; }
.lk__btn.is-beat .lk__heart {
animation-name: lk-beat;
animation-duration: $dur-base;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes lk-beat {
0% { transform: scale(1); }
35% { transform: scale(1.45); }
70% { transform: scale(.92); }
100% { transform: scale(1); }
}
.lk__note {
margin: 0;
max-width: 260px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 35% 에 누르고 80% 에 다시 눌러 되돌린다. 굴림은 transform 이라 중간 프레임이 유령이 되지 않는다 ──
.lk.is-demo .lk__roll {
animation-name: lk-roll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lk-roll {
0% { transform: translateY(0); }
32% { transform: translateY(0); }
44% { transform: translateY(-22px); }
78% { transform: translateY(-22px); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
.lk.is-demo .lk__heart {
animation-name: lk-beat-loop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lk-beat-loop {
0% { transform: scale(1); }
32% { transform: scale(1); }
40% { transform: scale(1.5); }
50% { transform: scale(.94); }
58% { transform: scale(1); }
78% { transform: scale(1); }
84% { transform: scale(1.2); }
90% { transform: scale(1); }
100% { transform: scale(1); }
}
.lk.is-demo .lk__btn {
animation-name: lk-skin;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lk-skin {
0% { border-color: $subject-ink; color: $subject-ink; }
36% { border-color: $color; color: $color; }
82% { border-color: $subject-ink; color: $subject-ink; }
100% { border-color: $subject-ink; color: $subject-ink; }
}
@media (max-width: 340px) {
.lk { gap: $sp-2; }
.lk__btn { height: 44px; padding: $sp-2 $sp-4; }
.lk__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.lk.is-demo .lk__roll,
.lk.is-demo .lk__heart,
.lk.is-demo .lk__btn,
.lk__btn.is-beat .lk__heart { animation: none !important; }
.lk:not(.is-demo) .lk__roll { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lk__btn {
box-sizing: border-box;
margin: 0;
padding: 8px 20px;
display: inline-flex;
align-items: center;
gap: 8px;
height: 48px;
border: 2px solid #17141a;
border-radius: 999px;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.lk__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.lk__heart {
display: inline-flex;
flex: 0 0 auto;
}
.lk__heart svg {
display: block;
}
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll {
display: block;
transform: translateY(0);
}
.lk__n {
display: block;
height: 22px;
font-size: 15px;
line-height: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.lk:not(.is-demo) .lk__roll {
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.lk:not(.is-demo) .lk__btn[aria-pressed=true] .lk__roll {
transform: translateY(-22px);
}
.lk:not(.is-demo) .lk__btn[aria-pressed=true] {
border-color: #ff4d1f;
color: #ff4d1f;
}
.lk__btn.is-beat .lk__heart {
animation-name: lk-beat;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes lk-beat {
0% {
transform: scale(1);
}
35% {
transform: scale(1.45);
}
70% {
transform: scale(0.92);
}
100% {
transform: scale(1);
}
}
.lk__note {
margin: 0;
max-width: 260px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.lk.is-demo .lk__roll {
animation-name: lk-roll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes lk-roll {
0% {
transform: translateY(0);
}
32% {
transform: translateY(0);
}
44% {
transform: translateY(-22px);
}
78% {
transform: translateY(-22px);
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.lk.is-demo .lk__heart {
animation-name: lk-beat-loop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes lk-beat-loop {
0% {
transform: scale(1);
}
32% {
transform: scale(1);
}
40% {
transform: scale(1.5);
}
50% {
transform: scale(0.94);
}
58% {
transform: scale(1);
}
78% {
transform: scale(1);
}
84% {
transform: scale(1.2);
}
90% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.lk.is-demo .lk__btn {
animation-name: lk-skin;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lk-skin {
0% {
border-color: #17141a;
color: #17141a;
}
36% {
border-color: #ff4d1f;
color: #ff4d1f;
}
82% {
border-color: #17141a;
color: #17141a;
}
100% {
border-color: #17141a;
color: #17141a;
}
}
@media (max-width: 340px) {
.lk {
gap: 8px;
}
.lk__btn {
height: 44px;
padding: 8px 16px;
}
.lk__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.lk.is-demo .lk__roll,
.lk.is-demo .lk__heart,
.lk.is-demo .lk__btn,
.lk__btn.is-beat .lk__heart {
animation: none !important;
}
.lk:not(.is-demo) .lk__roll {
transition: none !important;
}
}// 실제 동작: 누른 상태와 숫자를 따로 두지 않는다. aria-pressed 하나가 하트 색과 굴림 위치를 같이 정한다
var root = document.querySelector('.lk');
var btn = document.getElementById('lk-btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
btn.classList.remove('is-beat');
void btn.offsetWidth; // 연달아 눌러도 하트 반동이 매번 돌게 리플로를 강제한다
btn.classList.add('is-beat');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LikeCountButton.module.scss";
export type LikeCountButtonProps = {
label?: string;
note?: string;
count?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 1248 → "1,248" — 세 자리마다 쉼표
function fmt(n: number): string {
const s = String(n);
let out = "";
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) out += ",";
out += s.charAt(i);
}
return out;
}
export default function LikeCountButton({
label = "Like this post",
note = "The number rolls up instead of being swapped",
count = 1248,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: LikeCountButtonProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState(false);
const [beat, setBeat] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누른 상태 하나가 하트 색과 굴림 위치를 같이 정한다
function press() {
setDemo(false);
setOn((v) => !v);
setBeat((v) => v + 1); // key 를 바꿔 연달아 눌러도 하트 반동이 매번 다시 돈다
}
return (
<div className={`${styles.lk} ${demo ? styles.isDemo : ""}`} style={vars}>
<button className={styles.btn} type="button" aria-pressed={on} aria-label={label} onClick={press}>
<span key={beat} className={`${styles.heart} ${demo ? "" : styles.isBeat}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
</span>
<span className={styles.win} aria-live="polite">
<span className={styles.roll}>
<span className={styles.n}>{fmt(count)}</span>
<span className={styles.n}>{fmt(count + 1)}</span>
</span>
</span>
</button>
<p className={styles.note}>{note}</p>
</div>
);
}04 · 숫자가 굴러 올라가는 좋아요 — 측정 누적면적 1.325% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
숫자 폭이 자리마다 달라지면 창이 흔들리므로 font-variant-numeric: tabular-nums 로 자릿수 폭을 고정합니다.
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll { display: block; transform: translateY(0); }
.lk__n { display: block; height: 22px; font-variant-numeric: tabular-nums; }
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }
05버튼 자신이 진행 막대가 된다
화면에서 일어나는 일
퍼센트를 변수 하나에만 적고, 채움 너비와 화면에 찍히는 숫자가 둘 다 그 변수를 읽습니다. 그런데 그냥 --p: 0 이라고 두면 브라우저는 그 값을 글자로만 보기 때문에 0에서 100으로 흐르지 않고 뚝뚝 끊깁니다.

<main class="stage">
<div class="dl is-demo">
<button class="dl__btn" id="dl-btn" type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="dl__fill" aria-hidden="true"></span>
<span class="dl__face" data-face="idle">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z"/></g></svg></span>
<span class="dl__t">파일 받기</span>
</span>
<span class="dl__face" data-face="busy">
<span class="dl__pct"></span>
</span>
<span class="dl__face" data-face="done">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="dl__t">받았습니다</span>
</span>
</button>
<p class="dl__note">버튼 안쪽이 그대로 진행 막대입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
// 등록하지 않은 커스텀 속성은 브라우저가 글자로만 보고 보간하지 않는다 — 숫자로 등록해야 0 에서 100 으로 흐른다
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
box-shadow: $shadow-raised;
}
.dl__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 채움 — 단일 변수 --p 가 그라디언트의 색 경계 자리를 정한다
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.dl__ic { display: inline-flex; flex: 0 0 auto; }
.dl__ic svg { display: block; }
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 같은 변수를 숫자로도 읽는다 — 카운터에 넣으면 글자가 된다
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
// 사람이 실제로 누른 경로
.dl:not(.is-demo) .dl__btn[data-state="idle"] .dl__face[data-face="idle"],
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"],
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__face[data-face="done"] { opacity: 1; }
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"] { color: $subject-cream; }
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__fill { background-image: none; background-color: $subject-mint; }
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
}
// ── 자동 루프: 12% 에 시작해 78% 에 100% 를 채우고 82% 에 완료로 끊었다가 88% 에 원복 ──
// --p 는 등속으로 흐르고, 완료 색은 한 프레임에 끊는다 — 한 요소에 두 애니메이션을 겹쳐 건다
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% { --p: 0; }
12% { --p: 0; }
78% { --p: 100; }
100% { --p: 100; }
}
@keyframes dl-skin {
0% { background-color: $subject-cream; }
82% { background-color: $subject-mint; }
88% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
.dl.is-demo .dl__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face="idle"] { animation-name: dl-idle; }
.dl.is-demo .dl__face[data-face="busy"] { animation-name: dl-busy; }
.dl.is-demo .dl__face[data-face="done"] { animation-name: dl-done; }
@keyframes dl-idle {
0% { opacity: 1; }
12% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes dl-busy {
0% { opacity: 0; color: $subject-ink; }
12% { opacity: 1; color: $subject-ink; }
40% { opacity: 1; color: $subject-cream; }
82% { opacity: 0; color: $subject-cream; }
100% { opacity: 0; color: $subject-cream; }
}
@keyframes dl-done {
0% { opacity: 0; }
82% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 완료로 끊는 동안은 채움 띠를 걷어 버튼 자체 색이 그대로 보이게 한다
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% { opacity: 1; }
82% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.dl { gap: $sp-2; }
.dl__btn { height: 44px; }
.dl__t { font-size: 13px; line-height: 18px; }
.dl__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill { animation: none !important; }
.dl.is-demo .dl__face[data-face="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dl__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, #2f6df6 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.dl__ic {
display: inline-flex;
flex: 0 0 auto;
}
.dl__ic svg {
display: block;
}
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dl:not(.is-demo) .dl__btn[data-state=idle] .dl__face[data-face=idle],
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy],
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__face[data-face=done] {
opacity: 1;
}
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy] {
color: #fff7e6;
}
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__fill {
background-image: none;
background-color: #4cd4a6;
}
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
}
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: 2s, 2s;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% {
--p: 0;
}
12% {
--p: 0;
}
78% {
--p: 100;
}
100% {
--p: 100;
}
}
@keyframes dl-skin {
0% {
background-color: #fff7e6;
}
82% {
background-color: #4cd4a6;
}
88% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
.dl.is-demo .dl__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face=idle] {
animation-name: dl-idle;
}
.dl.is-demo .dl__face[data-face=busy] {
animation-name: dl-busy;
}
.dl.is-demo .dl__face[data-face=done] {
animation-name: dl-done;
}
@keyframes dl-idle {
0% {
opacity: 1;
}
12% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-busy {
0% {
opacity: 0;
color: #17141a;
}
12% {
opacity: 1;
color: #17141a;
}
40% {
opacity: 1;
color: #fff7e6;
}
82% {
opacity: 0;
color: #fff7e6;
}
100% {
opacity: 0;
color: #fff7e6;
}
}
@keyframes dl-done {
0% {
opacity: 0;
}
82% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% {
opacity: 1;
}
82% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.dl {
gap: 8px;
}
.dl__btn {
height: 44px;
}
.dl__t {
font-size: 13px;
line-height: 18px;
}
.dl__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill {
animation: none !important;
}
.dl.is-demo .dl__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 퍼센트를 CSS 변수 --p 하나에만 적는다. 채움 너비도 숫자도 그 변수에서 나온다
var root = document.querySelector('.dl');
var btn = document.getElementById('dl-btn');
var timer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (btn.getAttribute('data-state') === 'busy') return;
var p = 0;
btn.setAttribute('data-state', 'busy');
clearInterval(timer);
timer = setInterval(function () {
p = Math.min(100, p + 4);
btn.style.setProperty('--p', p);
btn.setAttribute('aria-valuenow', String(p));
if (p === 100) { clearInterval(timer); btn.setAttribute('data-state', 'done'); }
}, 80);
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DownloadProgressButton.module.scss";
export type DownloadProgressButtonProps = {
label?: string;
doneLabel?: string;
note?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p"?: string;
};
type State = "idle" | "busy" | "done";
const STEP = 4; // 한 번에 올리는 퍼센트
const TICK = 80; // 올리는 간격(ms)
export default function DownloadProgressButton({
label = "Download",
doneLabel = "Saved",
note = "The inside of the button is the progress bar",
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: DownloadProgressButtonProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<State>("idle");
const [p, setP] = useState(0);
// 퍼센트를 --p 하나에만 적는다 — 채움 너비도 숫자도 그 변수에서 나온다
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const btnVars: Vars = demo ? {} : { "--p": String(p) };
useEffect(() => {
if (state !== "busy") return;
let v = 0;
setP(0);
const id = setInterval(() => {
v = Math.min(100, v + STEP);
setP(v);
if (v === 100) {
clearInterval(id);
setState("done");
}
}, TICK);
return () => clearInterval(id);
}, [state]);
function start() {
setDemo(false);
if (state === "busy") return;
setState("busy");
}
return (
<div className={`${styles.dl} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={styles.btn}
type="button"
style={btnVars}
data-state={state}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={demo ? 0 : p}
onClick={start}
>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z" />
</g>
</svg>
</span>
<span className={styles.t}>{label}</span>
</span>
<span className={styles.face} data-face="busy">
<span className={styles.pct} />
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
<p className={styles.note}>{note}</p>
</div>
);
}05 · 버튼 자신이 진행 막대가 된다 — 측정 누적면적 9.269% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
@property 로 정수라고 등록해야 비로소 보간되고, 같은 변수를 counter-reset 에 넣으면 숫자로도 찍힙니다.
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl__fill {
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-variant-numeric: tabular-nums;
}
06길게 눌러야 지워지는 삭제
어떤 움직임인가
누르고 있는 동안 둘레의 고리가 stroke-dashoffset 으로 한 바퀴 그려지고, 다 그려졌을 때만 삭제가 일어납니다. 손을 떼면 같은 트랜지션이 그대로 되감겨 아무 일도 없었던 것이 됩니다.

<main class="stage">
<div class="hd is-demo">
<div class="hd__dial">
<svg class="hd__ring" viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle class="hd__track" cx="44" cy="44" r="40" />
<circle class="hd__arc" cx="44" cy="44" r="40" />
</svg>
<button class="hd__btn" id="hd-btn" type="button" aria-label="길게 눌러 삭제">
<span class="hd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
</button>
</div>
<p class="hd__log" role="status" aria-live="polite">
<span class="hd__word" data-word="idle">길게 눌러 삭제</span>
<span class="hd__word" data-word="done">지웠습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1200ms;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-yellow);
$circumference: 251.33; // 2 × π × 40 — 고리를 한 바퀴로 만드는 길이
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg); // 12시 방향에서 그리기 시작한다
}
.hd__track {
fill: none;
stroke: $subject-cream;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: $color;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: $circumference;
stroke-dashoffset: $circumference;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: $subject-ink;
color: $subject-cream;
cursor: pointer;
touch-action: none; // 길게 누르는 동안 브라우저가 스크롤로 가로채지 않게
}
.hd__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.hd__ic { display: inline-flex; }
.hd__ic svg { display: block; }
// 사람이 실제로 누른 경로 — 누르는 동안 차고, 떼면 같은 길로 되감긴다
.hd:not(.is-demo) .hd__arc { transition: stroke-dashoffset $duration $easing; }
.hd:not(.is-demo).is-holding .hd__arc { stroke-dashoffset: 0; }
.hd:not(.is-demo).is-holding .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__arc { stroke-dashoffset: 0; }
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: $subject-ink;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word="idle"] { opacity: 1; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="idle"] { opacity: 0; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="done"] { opacity: 1; }
// ── 자동 루프: 10% 에 눌러 62% 까지 한 바퀴를 그리고, 68% 에 삭제로 끊었다가 86% 에 되감는다 ──
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% { stroke-dashoffset: $circumference; }
10% { stroke-dashoffset: $circumference; }
62% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
90% { stroke-dashoffset: $circumference; }
100% { stroke-dashoffset: $circumference; }
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% { background-color: $subject-ink; transform: scale(1); }
10% { background-color: $subject-ink; transform: scale(.92); }
68% { background-color: $color; transform: scale(1.06); }
86% { background-color: $subject-ink; transform: scale(1); }
100% { background-color: $subject-ink; transform: scale(1); }
}
.hd.is-demo .hd__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word="idle"] { animation-name: hd-idle; }
.hd.is-demo .hd__word[data-word="done"] { animation-name: hd-done; }
@keyframes hd-idle {
0% { opacity: 1; }
68% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes hd-done {
0% { opacity: 0; }
68% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.hd { gap: $sp-2; }
.hd__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word { animation: none !important; }
.hd:not(.is-demo) .hd__arc { transition: none !important; }
.hd.is-demo .hd__word[data-word="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg);
}
.hd__track {
fill: none;
stroke: #fff7e6;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: #ff4d1f;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: 251.33;
stroke-dashoffset: 251.33;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: #17141a;
color: #fff7e6;
cursor: pointer;
touch-action: none;
}
.hd__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.hd__ic {
display: inline-flex;
}
.hd__ic svg {
display: block;
}
.hd:not(.is-demo) .hd__arc {
transition: stroke-dashoffset 1200ms ease-out;
}
.hd:not(.is-demo).is-holding .hd__arc {
stroke-dashoffset: 0;
}
.hd:not(.is-demo).is-holding .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__arc {
stroke-dashoffset: 0;
}
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: #17141a;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word=idle] {
opacity: 1;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=idle] {
opacity: 0;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=done] {
opacity: 1;
}
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% {
stroke-dashoffset: 251.33;
}
10% {
stroke-dashoffset: 251.33;
}
62% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 0;
}
90% {
stroke-dashoffset: 251.33;
}
100% {
stroke-dashoffset: 251.33;
}
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% {
background-color: #17141a;
transform: scale(1);
}
10% {
background-color: #17141a;
transform: scale(0.92);
}
68% {
background-color: #ff4d1f;
transform: scale(1.06);
}
86% {
background-color: #17141a;
transform: scale(1);
}
100% {
background-color: #17141a;
transform: scale(1);
}
}
.hd.is-demo .hd__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word=idle] {
animation-name: hd-idle;
}
.hd.is-demo .hd__word[data-word=done] {
animation-name: hd-done;
}
@keyframes hd-idle {
0% {
opacity: 1;
}
68% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes hd-done {
0% {
opacity: 0;
}
68% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.hd {
gap: 8px;
}
.hd__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word {
animation: none !important;
}
.hd:not(.is-demo) .hd__arc {
transition: none !important;
}
.hd.is-demo .hd__word[data-word=idle] {
opacity: 1;
}
}// 실제 동작: 누르는 동안만 고리가 그려진다. 손을 떼면 되감기고, 한 바퀴를 다 그렸을 때만 삭제가 일어난다
var root = document.querySelector('.hd');
var btn = document.getElementById('hd-btn');
var arc = document.querySelector('.hd__arc');
var doneTimer = 0;
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
root.classList.add('is-holding');
clearTimeout(doneTimer);
doneTimer = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(doneTimer); root.classList.remove('is-holding'); });
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HoldToDeleteButton.module.scss";
export type HoldToDeleteButtonProps = {
label?: string;
doneLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// DOM 전역 타입을 끌어오지 않으려고 필요한 모양만 여기서 선언한다
type PtrEvt = { pointerId: number; currentTarget: { setPointerCapture(id: number): void } };
export default function HoldToDeleteButton({
label = "Hold to delete",
doneLabel = "Deleted",
duration = 1200,
easing = "ease-out",
color = "#ff4d1f",
}: HoldToDeleteButtonProps) {
const [demo, setDemo] = useState(true);
const [holding, setHolding] = useState(false);
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const stop = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
useEffect(() => stop, [stop]);
// 누르는 동안만 고리가 그려진다 — 한 바퀴를 다 그렸을 때만 삭제가 일어난다
function down(e: PtrEvt) {
setDemo(false);
setDone(false);
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
setHolding(true);
stop();
timer.current = setTimeout(() => {
setDone(true);
setHolding(false);
}, duration);
}
function release() {
stop();
setHolding(false);
}
return (
<div
className={`${styles.hd} ${demo ? styles.isDemo : ""} ${holding ? styles.isHolding : ""}`}
style={vars}
data-state={done ? "done" : "idle"}
>
<div className={styles.dial}>
<svg className={styles.ring} viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle className={styles.track} cx="44" cy="44" r="40" />
<circle className={styles.arc} cx="44" cy="44" r="40" />
</svg>
<button
className={styles.btn}
type="button"
aria-label={label}
onPointerDown={down}
onPointerUp={release}
onPointerCancel={release}
onPointerLeave={release}
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="26" height="26">
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
</svg>
</span>
</button>
</div>
<p className={styles.log} role="status" aria-live="polite">
<span className={styles.word} data-word="idle">{label}</span>
<span className={styles.word} data-word="done">{doneLabel}</span>
</p>
</div>
);
}06 · 길게 눌러야 지워지는 삭제 — 측정 누적면적 4.224% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
누르는 순간 setPointerCapture 를 걸어 두지 않으면 손가락이 버튼 밖으로 나갔을 때 취소 이벤트를 받지 못해 고리가 계속 찹니다.
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
root.classList.add('is-holding');
clearTimeout(doneTimer);
doneTimer = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(doneTimer); root.classList.remove('is-holding'); });
});
07주 동작과 다른 길이 붙은 버튼
무엇이 보이나
한 테두리 안에 들어 있지만 실제로는 button 이 둘이고, 목록을 여는 것은 오른쪽 화살표 칸뿐입니다. 왼쪽 넓은 칸은 가장 흔한 동작을 바로 실행하면서 목록을 닫기만 할 뿐 aria-expanded 를 참으로 만들지 않습니다.

<main class="stage">
<div class="sp is-demo">
<div class="sp__wrap">
<div class="sp__group" role="group" aria-label="보내기 선택">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="sp__t">바로 보내기</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="보내는 방법 더 보기">
<span class="sp__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
<li role="none"><button class="sp__item" type="button" role="menuitem">예약해서 보내기</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">임시 보관함에 넣기</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">보내기 전에 미리 보기</button></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.sp {
box-sizing: border-box;
width: min(260px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 열린 목록은 절대 배치라 부모 높이에 안 잡힌다 — 목록 높이만큼 자리를 미리 비워 둔다
.sp__wrap {
position: relative;
padding-bottom: 116px;
}
.sp__group {
display: flex;
align-items: stretch;
height: 44px;
border-radius: $r-control;
background-color: $subject-cream;
box-shadow: $shadow-raised;
}
.sp__main {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
border: 0;
border-radius: $r-control 0 0 $r-control;
background-color: transparent;
color: $color;
cursor: pointer;
}
.sp__main[data-sent="1"] { background-color: $subject-mint; }
.sp__more {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
flex: 0 0 auto;
display: flex;
align-items: center;
border: 0;
border-left: 2px solid $stage-orange; // 한 테두리 안에서 두 칸을 가르는 선
border-radius: 0 $r-control $r-control 0;
background-color: transparent;
color: $color;
cursor: pointer;
}
.sp__main:focus-visible,
.sp__more:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.sp__ic,
.sp__caret { display: inline-flex; flex: 0 0 auto; }
.sp__ic svg,
.sp__caret svg { display: block; }
.sp__t {
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sp__menu {
position: absolute;
left: 0;
right: 0;
top: 52px;
margin: 0;
padding: $sp-1;
list-style: none;
border-radius: $r-card;
background-color: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
}
.sp__item {
box-sizing: border-box;
margin: 0;
padding: 0 $sp-3;
display: block;
width: 100%;
height: 32px;
border: 0;
border-radius: $r-sm;
background-color: transparent;
color: $color;
text-align: left;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transform: translateY(-16px);
}
.sp__item:hover { background-color: $stage-yellow; }
.sp__item:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// 사람이 실제로 연 경로
.sp:not(.is-demo)[data-open="1"] .sp__menu { opacity: 1; }
.sp:not(.is-demo)[data-open="1"] .sp__item { transform: translateY(0); transition: transform $duration $easing; }
.sp:not(.is-demo)[data-open="1"] .sp__caret { transform: rotate(180deg); }
.sp:not(.is-demo) .sp__caret { transition: transform $duration $easing; }
.sp__menu[aria-hidden],
.sp:not(.is-demo):not([data-open="1"]) .sp__menu { pointer-events: none; }
// ── 자동 루프: 8% 에 열고 **닫지 않는다**. 목록이 사라지는 프레임이 가장 크게 변해 대표 이미지가 빈 버튼이 됐다 ──
// 대신 열린 뒤에는 줄을 하나씩 훑어 움직임을 채운다.
.sp.is-demo .sp__menu {
animation-name: sp-open;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-open {
0% { opacity: 0; }
8% { opacity: 1; }
100% { opacity: 1; }
}
.sp.is-demo .sp__item {
animation-name: sp-slide, sp-walk;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sp.is-demo li:nth-child(1) .sp__item { animation-delay: 0s, 0s; }
.sp.is-demo li:nth-child(2) .sp__item { animation-delay: 140ms, 360ms; }
.sp.is-demo li:nth-child(3) .sp__item { animation-delay: 280ms, 720ms; }
@keyframes sp-slide {
0% { transform: translateY(-16px); }
8% { transform: translateY(-16px); }
34% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes sp-walk {
0% { background-color: transparent; }
40% { background-color: $stage-yellow; }
58% { background-color: transparent; }
100% { background-color: transparent; }
}
.sp.is-demo .sp__caret {
animation-name: sp-turn;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-turn {
0% { transform: rotate(0deg); }
6% { transform: rotate(0deg); }
26% { transform: rotate(180deg); }
100% { transform: rotate(180deg); }
}
@media (max-width: 340px) {
.sp__wrap { padding-bottom: 100px; }
.sp__group { height: 40px; }
.sp__t { font-size: 12px; line-height: 18px; }
.sp__menu { top: 44px; }
.sp__item { height: 28px; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__menu,
.sp.is-demo .sp__item,
.sp.is-demo .sp__caret { animation: none !important; }
.sp:not(.is-demo) .sp__item,
.sp:not(.is-demo) .sp__caret { transition: none !important; }
.sp.is-demo .sp__menu { opacity: 1; }
.sp.is-demo .sp__item { transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
box-sizing: border-box;
width: min(260px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp__wrap {
position: relative;
padding-bottom: 116px;
}
.sp__group {
display: flex;
align-items: stretch;
height: 44px;
border-radius: 12px;
background-color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sp__main {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 12px 0 0 12px;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.sp__main[data-sent="1"] {
background-color: #4cd4a6;
}
.sp__more {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
flex: 0 0 auto;
display: flex;
align-items: center;
border: 0;
border-left: 2px solid #ff4d1f;
border-radius: 0 12px 12px 0;
background-color: transparent;
color: #17141a;
cursor: pointer;
}
.sp__main:focus-visible,
.sp__more:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.sp__ic,
.sp__caret {
display: inline-flex;
flex: 0 0 auto;
}
.sp__ic svg,
.sp__caret svg {
display: block;
}
.sp__t {
font-size: 14px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sp__menu {
position: absolute;
left: 0;
right: 0;
top: 52px;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 16px;
background-color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
}
.sp__item {
box-sizing: border-box;
margin: 0;
padding: 0 12px;
display: block;
width: 100%;
height: 32px;
border: 0;
border-radius: 8px;
background-color: transparent;
color: #17141a;
text-align: left;
font-family: inherit;
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transform: translateY(-16px);
}
.sp__item:hover {
background-color: #ffd23f;
}
.sp__item:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.sp:not(.is-demo)[data-open="1"] .sp__menu {
opacity: 1;
}
.sp:not(.is-demo)[data-open="1"] .sp__item {
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp:not(.is-demo)[data-open="1"] .sp__caret {
transform: rotate(180deg);
}
.sp:not(.is-demo) .sp__caret {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__menu[aria-hidden],
.sp:not(.is-demo):not([data-open="1"]) .sp__menu {
pointer-events: none;
}
.sp.is-demo .sp__menu {
animation-name: sp-open;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-open {
0% {
opacity: 0;
}
8% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sp.is-demo .sp__item {
animation-name: sp-slide, sp-walk;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sp.is-demo li:nth-child(1) .sp__item {
animation-delay: 0s, 0s;
}
.sp.is-demo li:nth-child(2) .sp__item {
animation-delay: 140ms, 360ms;
}
.sp.is-demo li:nth-child(3) .sp__item {
animation-delay: 280ms, 720ms;
}
@keyframes sp-slide {
0% {
transform: translateY(-16px);
}
8% {
transform: translateY(-16px);
}
34% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes sp-walk {
0% {
background-color: transparent;
}
40% {
background-color: #ffd23f;
}
58% {
background-color: transparent;
}
100% {
background-color: transparent;
}
}
.sp.is-demo .sp__caret {
animation-name: sp-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-turn {
0% {
transform: rotate(0deg);
}
6% {
transform: rotate(0deg);
}
26% {
transform: rotate(180deg);
}
100% {
transform: rotate(180deg);
}
}
@media (max-width: 340px) {
.sp__wrap {
padding-bottom: 100px;
}
.sp__group {
height: 40px;
}
.sp__t {
font-size: 12px;
line-height: 18px;
}
.sp__menu {
top: 44px;
}
.sp__item {
height: 28px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__menu,
.sp.is-demo .sp__item,
.sp.is-demo .sp__caret {
animation: none !important;
}
.sp:not(.is-demo) .sp__item,
.sp:not(.is-demo) .sp__caret {
transition: none !important;
}
.sp.is-demo .sp__menu {
opacity: 1;
}
.sp.is-demo .sp__item {
transform: translateY(0);
}
}// 실제 동작: 왼쪽 칸은 목록을 열지 않는다. 오른쪽 화살표 칸만 aria-expanded 를 뒤집는다
var root = document.querySelector('.sp');
var more = document.getElementById('sp-more');
var main = document.getElementById('sp-main');
function setOpen(on) {
more.setAttribute('aria-expanded', String(on));
root.setAttribute('data-open', on ? '1' : '0');
}
more.addEventListener('click', function () {
root.classList.remove('is-demo');
setOpen(more.getAttribute('aria-expanded') !== 'true');
});
main.addEventListener('click', function () {
root.classList.remove('is-demo');
setOpen(false);
main.setAttribute('data-sent', '1');
});
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); more.focus({ preventScroll: true }); }
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SplitMenuButton.module.scss";
export type SplitMenuButtonProps = {
mainLabel?: string;
items?: string[];
groupLabel?: string;
moreLabel?: string;
id?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// DOM 전역 타입을 react 에서 가져오지 않는다 — 쓰는 속성만 여기에 적는다
type KeyEvt = { key: string };
const SEND_ICON = (
<g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" /><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" /></g>
);
const CARET_ICON = (
<g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2" /><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" /></g>
);
export default function SplitMenuButton({
mainLabel = "Send now",
items = ["Schedule send", "Save to drafts", "Preview first"],
groupLabel = "Send options",
moreLabel = "More ways to send",
id = "sp",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
}: SplitMenuButtonProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [open, setOpen] = useState(false);
const [sent, setSent] = useState(false);
const moreRef = useRef<HTMLButtonElement | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const menuId = `${id}-menu`;
const moreId = `${id}-more`;
// 왼쪽 칸은 목록을 열지 않는다. 오른쪽 화살표 칸만 aria-expanded 를 뒤집는다
const toggle = useCallback(() => {
setDemo(false);
setOpen((v) => !v);
}, []);
const send = useCallback(() => {
setDemo(false);
setOpen(false);
setSent(true);
}, []);
const onKeyDown = useCallback((e: KeyEvt) => {
if (e.key !== "Escape") return;
setOpen(false);
// preventScroll — iframe 안에서 부모 페이지가 끌려가지 않게
moreRef.current?.focus({ preventScroll: true });
}, []);
return (
<div
className={`${styles.sp} ${demo ? styles.isDemo : ""}`}
style={vars}
data-open={open ? "1" : "0"}
onKeyDown={onKeyDown}
>
<div className={styles.wrap}>
<div className={styles.group} role="group" aria-label={groupLabel}>
<button className={styles.main} type="button" data-sent={sent ? "1" : "0"} onClick={send}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{SEND_ICON}</svg>
</span>
<span className={styles.t}>{mainLabel}</span>
</button>
<button
ref={moreRef}
className={styles.more}
id={moreId}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
aria-label={moreLabel}
onClick={toggle}
>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{CARET_ICON}</svg>
</span>
</button>
</div>
<ul className={styles.menu} id={menuId} role="menu" aria-labelledby={moreId}>
{items.map((label) => (
<li key={label} role="none">
<button className={styles.item} type="button" role="menuitem">{label}</button>
</li>
))}
</ul>
</div>
</div>
);
}07 · 주 동작과 다른 길이 붙은 버튼 — 측정 누적면적 24.579% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
목록 자체는 role="menu" 이고 줄마다 role="menuitem" 이라 키보드로도 같은 길이 열립니다.
<div class="sp__group" role="group" aria-label="보내기 선택">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__t">바로 보내기</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="보내는 방법 더 보기">
<span class="sp__caret" aria-hidden="true"></span>
</button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
<li role="none"><button class="sp__item" type="button" role="menuitem">예약해서 보내기</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">임시 보관함에 넣기</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">보내기 전에 미리 보기</button></li>
</ul>
08고른 칸을 따라 미끄러지는 알약
화면에서 일어나는 일
세 칸은 flex: 0 0 auto 에 폭이 80px 로 못 박혀 있고, 알약은 그 값의 배수만큼만 옮겨 갑니다. 칸을 균등하게 나누는 flex: 1 1 0 과 고정 이동폭을 섞으면 실제로 그려진 칸 폭이 짐작과 달라져 알약이 옆 라벨에 걸칩니다.

<main class="stage">
<div class="sg is-demo">
<div class="sg__bar" role="radiogroup" aria-label="결제 주기 고르기" data-at="0">
<span class="sg__pill" aria-hidden="true"></span>
<button class="sg__seg" type="button" role="radio" aria-checked="true" data-at="0">월간</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="1">연간</button>
<button class="sg__seg" type="button" role="radio" aria-checked="false" data-at="2">평생</button>
</div>
<p class="sg__note">칸 폭을 못 박아야 알약이 어긋나지 않습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-paper);
$segment-width: 80px; // 세 칸을 같은 폭으로 못 박는다. 알약이 이 값의 배수만큼만 움직이므로 어긋날 자리가 없다
.sg {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__bar {
position: relative;
box-sizing: border-box;
display: flex;
padding: $sp-1;
border-radius: $r-pill;
background-color: $subject-ink;
}
.sg__pill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: $segment-width;
border-radius: $r-pill;
background-color: $color;
transform: translateX(0);
}
.sg__seg {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
flex: 0 0 auto; // 균등분배(flex: 1 1 0)와 섞으면 알약의 고정 이동폭과 실제 칸 폭이 어긋난다
width: $segment-width;
height: 36px;
border: 0;
border-radius: $r-pill;
background-color: transparent;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.sg__seg:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.sg__seg[aria-checked="true"] { color: $subject-ink; }
// 사람이 실제로 고른 경로
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($segment-width); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($segment-width * 2); }
.sg__note {
margin: 0;
max-width: 288px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-ink;
}
// ── 자동 루프: 알약은 transform 으로 미끄러지고, 글자 색만 한 프레임에 끊는다 ──
.sg.is-demo .sg__pill {
animation-name: sg-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sg-slide {
0% { transform: translateX(0); }
18% { transform: translateX(0); }
34% { transform: translateX($segment-width); }
50% { transform: translateX($segment-width); }
64% { transform: translateX($segment-width * 2); }
76% { transform: translateX($segment-width * 2); }
90% { transform: translateX(0); }
100% { transform: translateX(0); }
}
.sg.is-demo .sg__seg {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__seg[data-at="0"] { animation-name: sg-a; }
.sg.is-demo .sg__seg[data-at="1"] { animation-name: sg-b; }
.sg.is-demo .sg__seg[data-at="2"] { animation-name: sg-c; }
@keyframes sg-a {
0% { color: $subject-ink; }
26% { color: $subject-cream; }
84% { color: $subject-ink; }
100% { color: $subject-ink; }
}
@keyframes sg-b {
0% { color: $subject-cream; }
26% { color: $subject-ink; }
58% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@keyframes sg-c {
0% { color: $subject-cream; }
58% { color: $subject-ink; }
84% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@media (max-width: 340px) {
.sg { gap: $sp-2; }
.sg__note { font-size: 11px; line-height: 16px; max-width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill,
.sg.is-demo .sg__seg { animation: none !important; }
.sg:not(.is-demo) .sg__pill { transition: none !important; }
.sg.is-demo .sg__seg[data-at="0"] { color: $subject-ink; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sg {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__bar {
position: relative;
box-sizing: border-box;
display: flex;
padding: 4px;
border-radius: 999px;
background-color: #17141a;
}
.sg__pill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 80px;
border-radius: 999px;
background-color: #ffd23f;
transform: translateX(0);
}
.sg__seg {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
flex: 0 0 auto;
width: 80px;
height: 36px;
border: 0;
border-radius: 999px;
background-color: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.sg__seg:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.sg__seg[aria-checked=true] {
color: #17141a;
}
.sg:not(.is-demo) .sg__pill {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill {
transform: translateX(80px);
}
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill {
transform: translateX(160px);
}
.sg__note {
margin: 0;
max-width: 288px;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #17141a;
}
.sg.is-demo .sg__pill {
animation-name: sg-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sg-slide {
0% {
transform: translateX(0);
}
18% {
transform: translateX(0);
}
34% {
transform: translateX(80px);
}
50% {
transform: translateX(80px);
}
64% {
transform: translateX(160px);
}
76% {
transform: translateX(160px);
}
90% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
.sg.is-demo .sg__seg {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__seg[data-at="0"] {
animation-name: sg-a;
}
.sg.is-demo .sg__seg[data-at="1"] {
animation-name: sg-b;
}
.sg.is-demo .sg__seg[data-at="2"] {
animation-name: sg-c;
}
@keyframes sg-a {
0% {
color: #17141a;
}
26% {
color: #fff7e6;
}
84% {
color: #17141a;
}
100% {
color: #17141a;
}
}
@keyframes sg-b {
0% {
color: #fff7e6;
}
26% {
color: #17141a;
}
58% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@keyframes sg-c {
0% {
color: #fff7e6;
}
58% {
color: #17141a;
}
84% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@media (max-width: 340px) {
.sg {
gap: 8px;
}
.sg__note {
font-size: 11px;
line-height: 16px;
max-width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill,
.sg.is-demo .sg__seg {
animation: none !important;
}
.sg:not(.is-demo) .sg__pill {
transition: none !important;
}
.sg.is-demo .sg__seg[data-at="0"] {
color: #17141a;
}
}// 실제 동작: 고른 칸은 aria-checked 로 적고, 알약은 칸 번호 × 칸 폭만큼만 옮긴다
var root = document.querySelector('.sg');
var bar = root.querySelector('.sg__bar');
var segs = [].slice.call(root.querySelectorAll('.sg__seg'));
function select(i) {
bar.setAttribute('data-at', String(i));
segs.forEach(function (b, k) { b.setAttribute('aria-checked', String(k === i)); });
}
segs.forEach(function (b, i) {
b.addEventListener('click', function () { root.classList.remove('is-demo'); select(i); });
});
bar.addEventListener('keydown', function (e) {
var i = segs.indexOf(document.activeElement);
if (i < 0 || (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft')) return;
e.preventDefault();
root.classList.remove('is-demo');
var n = (i + (e.key === 'ArrowRight' ? 1 : 2)) % 3;
select(n);
segs[n].focus({ preventScroll: true }); // iframe 안에서 부모 페이지가 끌려가지 않게
});import { useCallback, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SegmentedRadioGroup.module.scss";
export type SegmentedRadioGroupProps = {
labels?: [string, string, string]; // 알약 이동폭이 80px 고정이라 칸은 세 개다
groupLabel?: string;
note?: string;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// DOM 전역 타입을 react 에서 가져오지 않는다 — 쓰는 속성만 여기에 적는다
type KeyEvt = { key: string; preventDefault(): void };
type Focusable = { focus(opt: { preventScroll: boolean }): void };
export default function SegmentedRadioGroup({
labels = ["Monthly", "Yearly", "Lifetime"],
groupLabel = "Choose a billing cycle",
note = "Pin the segment width so the pill never drifts",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
}: SegmentedRadioGroupProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [at, setAt] = useState(0);
const segs = useRef<(Focusable | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const pick = useCallback((i: number) => {
setDemo(false);
setAt(i);
}, []);
// 좌우 화살표로 이동 — 활성 요소를 뒤지지 않고 칸 번호를 그대로 쓴다
const onKeyDown = useCallback((e: KeyEvt, i: number) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
setDemo(false);
const n = (i + (e.key === "ArrowRight" ? 1 : labels.length - 1)) % labels.length;
setAt(n);
// preventScroll — iframe 안에서 부모 페이지가 끌려가지 않게
const segEls = segs.current;
if (segEls) segEls[n]?.focus({ preventScroll: true });
}, [labels.length]);
return (
<div className={`${styles.sg} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.bar} role="radiogroup" aria-label={groupLabel} data-at={String(at)}>
<span className={styles.pill} aria-hidden="true" />
{labels.map((label, i) => (
<button
key={label}
ref={(el: Focusable | null) => { const segEls = segs.current; if (segEls) segEls[i] = el; }}
className={styles.seg}
type="button"
role="radio"
aria-checked={at === i}
data-at={String(i)}
onClick={() => pick(i)}
onKeyDown={(e: KeyEvt) => onKeyDown(e, i)}
>
{label}
</button>
))}
</div>
<p className={styles.note}>{note}</p>
</div>
);
}08 · 고른 칸을 따라 미끄러지는 알약 — 측정 누적면적 5.811% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
고른 칸은 aria-checked 에 적히고 글자 색도 그 속성에서 갈립니다.
.sg__pill {
position: absolute;
width: $segment-width;
border-radius: $r-pill;
transform: translateX(0);
}
.sg__seg {
flex: 0 0 auto;
width: $segment-width;
height: 36px;
}
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($segment-width); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($segment-width * 2); }
09복사했다고 잠깐만 말하는 버튼
어떤 움직임인가
navigator.clipboard.writeText 가 끝난 뒤에 라벨이 바뀌고, 1.6초가 지나면 조용히 돌아옵니다. 권한이 막힌 곳에서도 사람에게는 반응이 보여야 하므로 성공과 실패 양쪽에 같은 마무리 함수를 겁니다.

<main class="stage">
<div class="cp is-demo">
<div class="cp__row">
<code class="cp__code" id="cp-src">npm i godrich-ui</code>
<button class="cp__btn" id="cp-btn" type="button" aria-label="명령어를 클립보드로 복사">
<span class="cp__ring" aria-hidden="true"></span>
<span class="cp__face" data-face="idle">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span class="cp__t">복사</span>
</span>
<span class="cp__face" data-face="done">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="cp__t">복사됨</span>
</span>
</button>
</div>
<p class="cp__live" role="status" aria-live="polite">
<span class="cp__word">명령어를 복사했습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 $sp-3;
height: 44px;
border-radius: $r-sm;
background-color: $subject-cream;
color: $subject-ink;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
}
.cp__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 복사된 순간에만 한 번 퍼지는 테두리 — 되돌아올 때는 없으므로 대표 프레임이 '복사됨' 쪽으로 잡힌다
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid $color;
border-radius: $r-control;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.cp__ic { display: inline-flex; flex: 0 0 auto; }
.cp__ic svg { display: block; }
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 사람이 실제로 누른 경로
.cp:not(.is-demo) .cp__face[data-face="idle"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="idle"] { opacity: 0; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="done"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__btn { background-color: $color; }
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word { opacity: 1; }
// ── 자동 루프: 35% 에 복사로 끊고, 되돌림은 76%(아이콘)·84%(색·라벨) 로 나눠 한 번에 커지지 않게 한다 ──
.cp.is-demo .cp__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face="idle"] { animation-name: cp-idle; }
.cp.is-demo .cp__face[data-face="done"] { animation-name: cp-done; }
@keyframes cp-idle {
0% { opacity: 1; }
35% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cp-done {
0% { opacity: 0; }
35% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% { background-color: $subject-cream; }
35% { background-color: $color; }
84% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
@keyframes cp-tap {
0% { transform: scale(1); }
30% { transform: scale(1); }
35% { transform: scale(.92); }
44% { transform: scale(1.04); }
54% { transform: scale(1); }
100% { transform: scale(1); }
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% { opacity: 0; transform: scale(1); }
35% { opacity: 1; transform: scale(1); }
52% { opacity: 0; transform: scale(1.4); }
100% { opacity: 0; transform: scale(1.4); }
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% { opacity: 0; }
35% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.cp { gap: $sp-2; }
.cp__row { gap: $sp-1; }
.cp__code { font-size: 11px; line-height: 16px; padding: 0 $sp-2; }
.cp__btn { width: 76px; }
.cp__t { font-size: 11px; line-height: 16px; }
.cp__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word { animation: none !important; }
.cp.is-demo .cp__face[data-face="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: 8px;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 12px;
height: 44px;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
}
.cp__btn:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 3px;
}
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid #4cd4a6;
border-radius: 12px;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.cp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.cp__ic svg {
display: block;
}
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp:not(.is-demo) .cp__face[data-face=idle] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=idle] {
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=done] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__btn {
background-color: #4cd4a6;
}
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word {
opacity: 1;
}
.cp.is-demo .cp__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face=idle] {
animation-name: cp-idle;
}
.cp.is-demo .cp__face[data-face=done] {
animation-name: cp-done;
}
@keyframes cp-idle {
0% {
opacity: 1;
}
35% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cp-done {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% {
background-color: #fff7e6;
}
35% {
background-color: #4cd4a6;
}
84% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
@keyframes cp-tap {
0% {
transform: scale(1);
}
30% {
transform: scale(1);
}
35% {
transform: scale(0.92);
}
44% {
transform: scale(1.04);
}
54% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% {
opacity: 0;
transform: scale(1);
}
35% {
opacity: 1;
transform: scale(1);
}
52% {
opacity: 0;
transform: scale(1.4);
}
100% {
opacity: 0;
transform: scale(1.4);
}
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cp {
gap: 8px;
}
.cp__row {
gap: 4px;
}
.cp__code {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
}
.cp__btn {
width: 76px;
}
.cp__t {
font-size: 11px;
line-height: 16px;
}
.cp__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word {
animation: none !important;
}
.cp.is-demo .cp__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 같은 말이 읽히는 영역으로도 한 번 나간다
var root = document.querySelector('.cp');
var btn = document.getElementById('cp-btn');
var src = document.getElementById('cp-src');
var resetTimer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var finish = function () {
root.setAttribute('data-copied', '1');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(finish, finish);
} else {
finish();
}
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyFeedbackButton.module.scss";
export type CopyFeedbackButtonProps = {
code?: string;
idleLabel?: string;
doneLabel?: string;
liveMessage?: string;
buttonLabel?: string;
holdMs?: number;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
const COPY_ICON = (
<g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g>
);
const DONE_ICON = (
<g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>
);
export default function CopyFeedbackButton({
code = "npm i godrich-ui",
idleLabel = "Copy",
doneLabel = "Copied",
liveMessage = "Command copied",
buttonLabel = "Copy the command to the clipboard",
holdMs = 1600,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
}: CopyFeedbackButtonProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
useEffect(() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
}, []);
// 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 실패해도 같은 길로 되돌아온다
const copy = useCallback(() => {
setDemo(false);
const finish = () => {
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), holdMs);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(finish, finish);
} else {
finish();
}
}, [code, holdMs]);
return (
<div
className={`${styles.cp} ${demo ? styles.isDemo : ""}`}
style={vars}
data-copied={copied ? "1" : "0"}
>
<div className={styles.row}>
<code className={styles.code}>{code}</code>
<button className={styles.btn} type="button" aria-label={buttonLabel} onClick={copy}>
<span className={styles.ring} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{COPY_ICON}</svg>
</span>
<span className={styles.t}>{idleLabel}</span>
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{DONE_ICON}</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
</div>
{/* 같은 말이 읽히는 영역으로도 한 번 나간다 */}
<p className={styles.live} role="status" aria-live="polite">
<span className={styles.word}>{liveMessage}</span>
</p>
</div>
);
}09 · 복사했다고 잠깐만 말하는 버튼 — 측정 누적면적 4.763% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
잠깐 바뀌는 라벨은 눈에만 보이기 때문에 같은 말이 role="status" 영역으로도 한 번 나갑니다.
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var finish = function () {
root.setAttribute('data-copied', '1');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(finish, finish);
} else {
finish();
}
});
어디서 깨지는가 — 함정
상태가 바뀌는 부품은 대표 이미지에서 먼저 무너집니다. 데모마다 프레임 24장을 뽑고 그중 앞 장과 가장 크게 달라진 한 장을 대표로 고르는데, 07번 첫 판은 루프에서 목록을 열었다가 다시 닫았습니다. 목록이 사라지는 프레임이 열리는 프레임보다 언제나 크게 변하기 때문에, 대표 이미지가 주황 바탕에 닫힌 버튼 하나만 남은 장면으로 나왔습니다. 고친 방법은 루프에서 목록을 아예 닫지 않는 것입니다. 8%에 한 번 열고 그대로 둔 뒤, 줄마다 다른 지연으로 translateY 를 풀고 그다음엔 배경색을 하나씩 훑게 했습니다. 그 결과 누적면적 24.58%에 움직인 프레임 12/23이 나왔고 대표 이미지는 목록이 펼쳐진 장면이 됐습니다.
두 번째는 컷과 프레임 수가 서로 부딪히는 문제입니다. 상태가 갈리는 순간을 steps(1, end) 로 끊으면 반투명하게 겹친 유령 프레임은 사라지지만, 컷만으로는 프레임이 두 장밖에 나오지 않습니다. 그래서 아홉 개 모두 컷 옆에 transform 으로 움직이는 작은 동작을 하나씩 붙였습니다. 01번은 계속 도는 스피너를, 03번은 scale 반동을, 09번은 복사된 순간에만 퍼지는 테두리를 겹쳤습니다. 09번은 되돌림도 한 번에 하지 않고 76%에 아이콘, 84%에 색과 라벨로 나눠 끊었습니다. 되돌리는 변화가 복사되는 변화보다 작아야 대표 이미지가 원하는 장면에 걸립니다. 세 번째는 눈으로만 보면 절대 걸리지 않는 함정이었습니다. 02번 루프는 가짜 입력 글자를 steps(8, end) 로 드러내는데, 폭을 108px 로 잡았더니 마지막 글자가 반쯤 잘려 [email protected] 으로 보였습니다. 120px 로 넓히고 나서야 주소가 온전해졌습니다. 한 바퀴가 아니라 계속 도는 알림이 필요하면 CSS 로딩 스피너가 div 하나로 그 일을 줄여 줍니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 sqph35dy 이고, 여기서 고친 값이 그대로 들어 있습니다.
네 번째는 크기입니다. 320×200 무대에서 여백을 빼면 세로로 쓸 수 있는 것은 174px 뿐인데, 07번의 목록은 절대 배치라서 부모 높이 계산에 잡히지 않습니다. 화면은 멀쩡해 보여도 문서 높이만 늘어나 격자 칸에 스크롤바가 생기므로, 감싸는 상자에 목록 높이만큼 padding-bottom 을 미리 비워 두었습니다. 좁은 화면에서는 그 값을 116px 에서 100px 로 줄입니다. 좁은 쪽에서는 간격을 반올림해 올리지 않고 내리는 것이 규칙입니다.
접근성
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜져 있으면 아홉 개 모두 자동 재생만 멈추고 지금 상태는 그대로 둡니다. 01번 스피너는 돌기를 멈추고, 05번 채움은 빈 채로 서 있으며, 06번 고리는 자라는 대신 한 번에 채워집니다. 상태를 알리는 속성은 어느 쪽에서도 꺼지지 않습니다.
| 항목 | 마우스 | 키보드 | 상태가 적히는 곳 |
|---|---|---|---|
| 01 비동기 한 바퀴 | 클릭 | Enter · Space | aria-busy · disabled |
| 02 조건 잠금 | 클릭 | Tab 초점 유지 | aria-disabled · aria-describedby |
| 03 토글 | 클릭 | Enter · Space | aria-pressed |
| 04 좋아요 | 클릭 | Enter · Space | aria-pressed · aria-live |
| 05 진행 | 클릭 | Enter · Space | role="progressbar" · aria-valuenow |
| 06 길게 누르기 | 누른 채 유지 | Enter · Space | role="status" |
| 07 스플릿 | 두 칸 각각 클릭 | Tab · Escape | aria-expanded · aria-haspopup |
| 08 세그먼트 | 클릭 | ← → 화살표 | role="radio" · aria-checked |
| 09 복사 | 클릭 | Enter · Space | aria-live="polite" |
토글 버튼의 눌림 상태를 왜 클래스가 아니라 속성에 적어야 하는지는 MDN 의 aria-pressed 문서에 정리되어 있습니다. 05번이 기대고 있는 커스텀 속성 등록도 MDN 의 @property 문서에 문법과 보간 조건이 그대로 적혀 있는데, syntax 를 빠뜨리면 등록 자체가 무시됩니다. 손으로 눌러서 시작하는 부품은 클릭 카테고리에, 화면에서 딱 하나만 눌리게 하고 싶은 버튼은 CTA 카테고리에 모여 있습니다.
FAQ
잠긴 버튼에 disabled 와 aria-disabled 중 뭘 써야 하나요?
이유를 알려 줄 것이 있으면 aria-disabled, 없으면 disabled 입니다. 진짜 disabled 는 초점을 받지 못해 키보드로 그 버튼에 닿는 순간 자체가 사라지고, 그러면 aria-describedby 로 걸어 둔 이유도 읽히지 않습니다. 대신 aria-disabled 는 브라우저가 클릭을 막아 주지 않으므로 눌렸을 때 아무 일도 하지 않는 코드를 직접 써야 합니다. 눌림 자체를 한 줄로 남기고 싶다면 CSS 버튼 눌림 효과가 짧은 판을 들고 있습니다.
css 버튼 클릭 애니메이션에서 로딩 상태를 유지하는 시간은 얼마가 적당한가요?
완료 표시는 2초면 충분합니다. 01번은 응답을 1.2초로 두고 완료를 2초 보여 준 뒤 되돌리는데, 이보다 짧으면 결과를 놓치고 길면 다음 동작이 막힌 것처럼 느껴집니다. 응답이 언제 올지 모르는 일이라면 완료 시각이 아니라 응답 시각에 맞춰 끊고, 완료 표시만 고정 시간으로 두는 편이 안전합니다.
아홉 개를 한 프로젝트에 같이 넣어도 되나요?
클래스 접두사가 항목마다 달라서 충돌하지 않습니다. 다만 05번의 @property --p 는 문서 전체에 한 번만 등록되는 전역 이름이라, 다른 곳에서 같은 이름을 다른 syntax 로 다시 등록하면 나중에 등록한 쪽이 이깁니다. 여러 진행 막대를 함께 쓸 계획이면 이름을 --dl-p 처럼 부품 이름으로 바꿔 두는 편이 낫습니다.









