바이브코딩 마이크로 인터랙션 9종 — 한 줄 복붙으로
마이크로 인터랙션은 클릭 한 번, 저장 한 번처럼 아주 작은 동작에 화면이 곧바로 반응해 주는 짧은 피드백입니다. 아홉 개 중 하나(04)는 서버 응답을 기다리지 않고 먼저 화면부터 바꿨다가, 실패했다고 가정하면 스스로 원래대로 되돌립니다.
01 vibe-coding-micro-interactions-press-scale
02 vibe-coding-micro-interactions-save-check
03 vibe-coding-micro-interactions-copy-done
04 vibe-coding-micro-interactions-optimistic-like
05 vibe-coding-micro-interactions-skeleton-to-content
06 vibe-coding-micro-interactions-empty-state-bounce
07 vibe-coding-micro-interactions-input-error-shake
08 vibe-coding-micro-interactions-tab-indicator-slide
09 vibe-coding-micro-interactions-toast-stack
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 버튼 눌림 스케일
- 02 저장 체크 애니메이션
- 03 복사 완료 피드백
- 04 낙관적 좋아요(즉시+되돌림)
- 05 스켈레톤→콘텐츠 크로스페이드
- 06 빈 상태 아이콘 바운스
- 07 입력 오류 흔들림
- 08 탭 인디케이터 슬라이드
- 09 토스트 쌓임
아홉 개 css 인터랙션은 인기 순이 아니라 화면에서 실제로 겪는 순서로 놓았습니다. 누르는 순간(01), 그 결과를 알려주는 완료 신호(02~03), 서버 응답을 기다리지 않고 먼저 보여주는 갱신과 로딩(04~05), 아무것도 없거나 값이 틀렸을 때(06~07), 여러 화면을 오가고 알림이 쌓이는 상황(08~09)입니다. 아래 코드는 상태가 바뀌는 규칙만 옮겼고, 클릭까지 반응하는 온전한 버전은 내려받는 압축 파일 쪽에서 확인하십시오. 상태 전환을 아예 화면 전체에 걸고 싶다면 CSS 다크모드 토글 애니메이션이 그 규모의 스위치를 다루고, 건너편에 짧은 설명을 띄우는 쪽은 CSS 툴팁 말풍선 9종입니다.
01버튼 눌림 스케일
어떤 움직임인가
버튼을 누르는 순간 transform: scale(1)이 scale(.96)으로 줄어들고 그림자도 함께 낮아져, 클릭이 실제로 눌렸다는 촉각적 확인을 줍니다.

<main class="stage">
<button class="btn is-demo" type="button">제출하기</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-quick; // 눌렸다 돌아오는 시간
$easing: $ease-spring; // 처음 빠르고 끝은 스프링처럼
$color: $subject-blue; // 버튼 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.btn {
border: 0;
border-radius: $r-control;
padding: clamp(14px, 5vw, 22px) clamp(28px, 9vw, 56px);
font-weight: 700;
font-size: clamp(15px, 4.4vw, 20px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transform: scale(1);
box-shadow: $shadow-raised;
transition: transform $duration $easing, box-shadow $duration $easing;
&:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
// 데모 · 문서용: 스스로 눌렸다 돌아오길 2초마다 반복한다
&.is-demo {
animation: press-loop $dur-loop $easing infinite;
}
}
@keyframes press-loop {
0%, 12% { transform: scale(1); box-shadow: $shadow-raised; }
22% { transform: scale(.96); box-shadow: $shadow-press; }
38%, 100% { transform: scale(1); box-shadow: $shadow-raised; }
}
// ── 접근성: 움직임 줄이기 설정이면 순간 전환만 ────────────
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn.is-demo { animation: none; }
.btn:active { transform: none; box-shadow: $shadow-press; }
}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);
}
.btn {
border: 0;
border-radius: 12px;
padding: clamp(14px, 5vw, 22px) clamp(28px, 9vw, 56px);
font-weight: 700;
font-size: clamp(15px, 4.4vw, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #2f6df6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn:active {
transform: scale(0.96);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.btn.is-demo {
animation: press-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes press-loop {
0%, 12% {
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
22% {
transform: scale(0.96);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
38%, 100% {
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.btn.is-demo {
animation: none;
}
.btn:active {
transform: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}// 실제 클릭에선 :active 만으로 눌림이 즉시 반응한다. 데모(.is-demo)는 손을 대면 자동 루프를 멈춘다.
document.querySelector('.btn').addEventListener('pointerdown', function (e) {
e.currentTarget.classList.remove('is-demo');
});import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./PressScaleButton.module.scss";
export type PressScaleButtonProps = {
children: ReactNode;
/** 눌렸다 돌아오는 시간 (기본 220ms) */
duration?: number;
/** CSS easing (기본 스프링) */
easing?: string;
/** 버튼 색 */
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit";
className?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function PressScaleButton({
children,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
onClick,
type = "button",
className = "",
}: PressScaleButtonProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type={type} className={`${styles.btn} ${className}`} style={vars} onClick={onClick}>
{children}
</button>
);
}01 · 버튼 눌림 스케일 — 측정 누적면적 1.256% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
버튼 인터랙션은 언제 쓰나
손을 떼면 스프링 이징으로 원래 크기로 돌아옵니다.
.btn {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
02저장 체크 애니메이션
무엇이 보이나
저장 버튼을 누르면 디스켓 모양이 scale·opacity로 사라지고, 체크 아이콘 두 획이 scaleX로 순서대로 그어지며, 원형 배지가 팝업 이징으로 부풀어 오릅니다.

<main class="stage">
<button class="save is-demo" type="button">
<span class="save__icon" aria-hidden="true">
<span class="save__ring"></span>
<span class="save__disk"></span>
<span class="save__check">
<span class="save__check-bar save__check-bar--short"></span>
<span class="save__check-bar save__check-bar--long"></span>
</span>
</span>
<span class="save__label">저장</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 디스켓 → 체크 전환 시간
$easing: $ease-pop; // 배지가 팝업하는 이징
$color: $subject-blue; // 완료 배지 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.save {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: $r-control;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { position: relative; width: 24px; height: 24px; flex: none; }
// 배지: 원형이 팝업하며 완료를 알린다
&__ring {
position: absolute; inset: -3px;
border-radius: 50%;
background: $color;
opacity: 0;
transform: scale(.4);
}
// 디스켓: 사각 + 오른쪽 위 노치
&__disk {
position: absolute; inset: 0;
border: 2.4px solid currentColor;
border-radius: 4px;
opacity: 1;
transform: scale(1);
&::after {
content: "";
position: absolute; top: 3px; right: 3px;
width: 7px; height: 6px;
background: currentColor;
}
}
// 체크: 두 개의 획이 각각 scaleX 로 그어진다
&__check { position: absolute; inset: 0; color: $subject-cream; }
&__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 체크 막대 같은 꼭짓점
}
&__check-bar--short { width: 6px; left: 8px; top: 14px; transform: rotate(225deg) scaleX(0); } // 두 막대가 같은 꼭짓점(8,14)에서 출발 — 어긋남 0
&__check-bar--long { width: 13px; left: 8px; top: 14px; transform: rotate(-52deg) scaleX(0); }
&.is-saved &__ring { animation: ring-pop $duration $easing forwards; }
&.is-saved &__disk { animation: disk-out $duration $easing forwards; }
&.is-saved &__check-bar--short { animation: check-short $duration $easing forwards; animation-delay: $duration * .35; }
&.is-saved &__check-bar--long { animation: check-long $duration $easing forwards; animation-delay: $duration * .5; }
&.is-demo &__ring { animation: ring-pop-loop $dur-loop $easing infinite; }
&.is-demo &__disk { animation: disk-out-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--short { animation: check-short-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--long { animation: check-long-loop $dur-loop $easing infinite; }
}
@keyframes ring-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
@keyframes disk-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(.5); } }
@keyframes check-short { to { transform: rotate(225deg) scaleX(1); } }
@keyframes check-long { to { transform: rotate(-52deg) scaleX(1); } }
@keyframes ring-pop-loop {
0%, 20% { opacity: 0; transform: scale(.4); }
40%, 85% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes disk-out-loop {
0%, 20% { opacity: 1; transform: scale(1); }
40%, 85% { opacity: 0; transform: scale(.5); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes check-short-loop {
0%, 27% { transform: rotate(225deg) scaleX(0); }
45%, 85% { transform: rotate(225deg) scaleX(1); }
100% { transform: rotate(225deg) scaleX(0); }
}
@keyframes check-long-loop {
0%, 32% { transform: rotate(-52deg) scaleX(0); }
50%, 85% { transform: rotate(-52deg) scaleX(1); }
100% { transform: rotate(-52deg) scaleX(0); }
}
// ── 접근성: 움직임 줄이기 설정이면 상태만 바뀌고 과정은 생략 ────
@media (prefers-reduced-motion: reduce) {
.save__ring, .save__disk, .save__check-bar { animation: none !important; }
.save.is-saved .save__ring { opacity: 1; transform: scale(1); }
.save.is-saved .save__disk { opacity: 0; }
.save.is-saved .save__check-bar--short { transform: rotate(225deg) scaleX(1); }
.save.is-saved .save__check-bar--long { transform: rotate(-52deg) scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.save {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: 12px;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.save__icon {
position: relative;
width: 24px;
height: 24px;
flex: none;
}
.save__ring {
position: absolute;
inset: -3px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
transform: scale(0.4);
}
.save__disk {
position: absolute;
inset: 0;
border: 2.4px solid currentColor;
border-radius: 4px;
opacity: 1;
transform: scale(1);
}
.save__disk::after {
content: "";
position: absolute;
top: 3px;
right: 3px;
width: 7px;
height: 6px;
background: currentColor;
}
.save__check {
position: absolute;
inset: 0;
color: #fff7e6;
}
.save__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
}
.save__check-bar--short {
width: 6px;
left: 8px;
top: 14px;
transform: rotate(225deg) scaleX(0);
}
.save__check-bar--long {
width: 13px;
left: 8px;
top: 14px;
transform: rotate(-52deg) scaleX(0);
}
.save.is-saved .save__ring {
animation: ring-pop 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.save.is-saved .save__disk {
animation: disk-out 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.save.is-saved .save__check-bar--short {
animation: check-short 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 105ms;
}
.save.is-saved .save__check-bar--long {
animation: check-long 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 150ms;
}
.save.is-demo .save__ring {
animation: ring-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.save.is-demo .save__disk {
animation: disk-out-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.save.is-demo .save__check-bar--short {
animation: check-short-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.save.is-demo .save__check-bar--long {
animation: check-long-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ring-pop {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes disk-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.5);
}
}
@keyframes check-short {
to {
transform: rotate(225deg) scaleX(1);
}
}
@keyframes check-long {
to {
transform: rotate(-52deg) scaleX(1);
}
}
@keyframes ring-pop-loop {
0%, 20% {
opacity: 0;
transform: scale(0.4);
}
40%, 85% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes disk-out-loop {
0%, 20% {
opacity: 1;
transform: scale(1);
}
40%, 85% {
opacity: 0;
transform: scale(0.5);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes check-short-loop {
0%, 27% {
transform: rotate(225deg) scaleX(0);
}
45%, 85% {
transform: rotate(225deg) scaleX(1);
}
100% {
transform: rotate(225deg) scaleX(0);
}
}
@keyframes check-long-loop {
0%, 32% {
transform: rotate(-52deg) scaleX(0);
}
50%, 85% {
transform: rotate(-52deg) scaleX(1);
}
100% {
transform: rotate(-52deg) scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.save__ring, .save__disk, .save__check-bar {
animation: none !important;
}
.save.is-saved .save__ring {
opacity: 1;
transform: scale(1);
}
.save.is-saved .save__disk {
opacity: 0;
}
.save.is-saved .save__check-bar--short {
transform: rotate(225deg) scaleX(1);
}
.save.is-saved .save__check-bar--long {
transform: rotate(-52deg) scaleX(1);
}
}// 실제 클릭 한 번 = 저장 완료 한 번. 데모(.is-demo) 루프는 손을 대면 멈추고 수동 상태로 바뀐다.
document.querySelector('.save').addEventListener('click', function (e) {
var b = e.currentTarget;
b.classList.remove('is-demo');
b.classList.remove('is-saved');
void b.offsetWidth;
b.classList.add('is-saved');
});import { useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./SaveCheckButton.module.scss";
export type SaveCheckButtonProps = {
label: string;
/** 디스켓 → 체크 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 완료 배지 강조색 */
color?: string;
onSave?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SaveCheckButton({
label,
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
onSave,
}: SaveCheckButtonProps) {
const [saved, setSaved] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const handle = (e: MouseEvent<HTMLButtonElement>) => {
setSaved(true);
onSave?.(e);
};
return (
<button
type="button"
className={saved ? `${styles.save} ${styles.saved}` : styles.save}
style={vars}
onClick={handle}
>
<span className={styles.icon} aria-hidden="true">
<span className={styles.ring} />
<span className={styles.disk} />
<span className={styles.check}>
<span className={`${styles.bar} ${styles.barShort}`} />
<span className={`${styles.bar} ${styles.barLong}`} />
</span>
</span>
<span>{label}</span>
</button>
);
}02 · 저장 체크 애니메이션 — 측정 누적면적 0.583% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.save.is-saved .save__disk {
animation: disk-out $duration $easing forwards;
}
.save.is-saved .save__check-bar--short {
animation: check-short $duration $easing forwards;
animation-delay: $duration * .35;
}
@keyframes check-short {
to { transform: rotate(45deg) scaleX(1); }
}
03복사 완료 피드백
화면에서 일어나는 일
복사 아이콘을 누르면 겹쳐진 두 사각형이 사라지고 체크 아이콘이 나타나며, 버튼 위 툴팁이 위로 살짝 뜬 채 1.2초 머물다가 사라집니다.

<main class="stage">
<button class="copy is-demo" type="button">
<span class="copy__icon" aria-hidden="true">
<span class="copy__ring"></span>
<span class="copy__sheet copy__sheet--back"></span>
<span class="copy__sheet copy__sheet--front"></span>
<span class="copy__check">
<span class="copy__check-bar copy__check-bar--short"></span>
<span class="copy__check-bar copy__check-bar--long"></span>
</span>
</span>
<span class="copy__label">코드 복사</span>
<span class="copy__tip" role="status">복사됨</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 아이콘 전환 시간
$easing: $ease-pop; // 배지가 팝업하는 이징
$color: $subject-cream; // 아이콘·툴팁 글자색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.copy {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: $r-control;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: $color;
background: $stage-ink;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { position: relative; width: 22px; height: 22px; flex: none; }
// 완료 배지: 체크가 도드라지도록 원형이 팝업한다
&__ring {
position: absolute; inset: -3px;
border-radius: 50%;
background: $subject-blue;
opacity: 0;
transform: scale(.4);
}
// 복사 아이콘: 겹친 두 사각형
&__sheet {
position: absolute;
width: 14px; height: 16px;
border: 2px solid currentColor;
border-radius: 3px;
transition: opacity $duration $easing, transform $duration $easing;
}
&__sheet--back { left: 0; top: 0; opacity: .55; }
&__sheet--front { left: 5px; top: 4px; background: $stage-ink; }
// 체크 아이콘: 두 획이 scaleX 로 그어진다
&__check { position: absolute; inset: 0; opacity: 0; }
&__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform-origin: left center; // 규칙8: 체크 막대 같은 꼭짓점
}
&__check-bar--short { width: 6px; left: 7px; top: 13px; transform: rotate(225deg) scaleX(0); } // 같은 꼭짓점(7,13)에서 출발
&__check-bar--long { width: 12px; left: 7px; top: 13px; transform: rotate(-52deg) scaleX(0); }
// 툴팁: 위로 살짝 떠올랐다가 사라진다
&__tip {
position: absolute;
left: 50%; bottom: 100%;
transform: translate(-50%, 4px);
margin-bottom: 8px;
padding: 5px 10px;
border-radius: $r-pill;
background: $stage-ink;
color: $color;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
&.is-copied &__ring { animation: ring-pop $duration $easing forwards; }
&.is-copied &__sheet--front { opacity: 0; transform: scale(.6); transition-delay: 0s; }
&.is-copied &__sheet--back { opacity: 0; transform: scale(.6); }
&.is-copied &__check { animation: check-in $duration $easing forwards; }
&.is-copied &__check-bar--short { animation: bar-short $duration $easing forwards; animation-delay: $duration * .3; }
&.is-copied &__check-bar--long { animation: bar-long $duration $easing forwards; animation-delay: $duration * .45; }
&.is-copied &__tip { animation: tip-float 1.4s $easing forwards; }
&.is-demo &__ring { animation: ring-pop-loop $dur-loop $easing infinite; }
&.is-demo &__sheet { animation: sheet-loop $dur-loop $easing infinite; }
&.is-demo &__check { animation: check-in-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--short { animation: bar-short-loop $dur-loop $easing infinite; }
&.is-demo &__check-bar--long { animation: bar-long-loop $dur-loop $easing infinite; }
&.is-demo &__tip { animation: tip-float-loop $dur-loop $easing infinite; }
}
@keyframes ring-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
@keyframes ring-pop-loop {
0%, 32% { opacity: 0; transform: scale(.4); }
40%, 78% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.4); }
}
@keyframes check-in { to { opacity: 1; } }
@keyframes bar-short { to { transform: rotate(225deg) scaleX(1); } }
@keyframes bar-long { to { transform: rotate(-52deg) scaleX(1); } }
@keyframes tip-float {
0% { opacity: 0; transform: translate(-50%, 4px); }
15% { opacity: 1; transform: translate(-50%, -2px); }
85% { opacity: 1; transform: translate(-50%, -2px); }
100% { opacity: 0; transform: translate(-50%, -10px); }
}
@keyframes sheet-loop {
0%, 18% { opacity: 1; transform: scale(1); }
35%, 82% { opacity: 0; transform: scale(.6); }
95%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes check-in-loop {
0%, 32% { opacity: 0; }
40%, 78% { opacity: 1; }
92%, 100% { opacity: 0; }
}
@keyframes bar-short-loop {
0%, 35% { transform: rotate(225deg) scaleX(0); }
45%, 75% { transform: rotate(225deg) scaleX(1); }
92%, 100% { transform: rotate(225deg) scaleX(0); }
}
@keyframes bar-long-loop {
0%, 40% { transform: rotate(-52deg) scaleX(0); }
50%, 75% { transform: rotate(-52deg) scaleX(1); }
92%, 100% { transform: rotate(-52deg) scaleX(0); }
}
@keyframes tip-float-loop {
0%, 15% { opacity: 0; transform: translate(-50%, 4px); }
32% { opacity: 1; transform: translate(-50%, -2px); }
75% { opacity: 1; transform: translate(-50%, -2px); }
90%, 100% { opacity: 0; transform: translate(-50%, -10px); }
}
// ── 접근성: 움직임 줄이기 설정이면 상태만 바뀌고 과정은 생략 ────
@media (prefers-reduced-motion: reduce) {
.copy__ring, .copy__sheet, .copy__check, .copy__check-bar, .copy__tip { animation: none !important; transition: none !important; }
.copy.is-copied .copy__ring { opacity: 1; transform: scale(1); }
.copy.is-copied .copy__sheet { opacity: 0; }
.copy.is-copied .copy__check { opacity: 1; }
.copy.is-copied .copy__check-bar--short { transform: rotate(225deg) scaleX(1); }
.copy.is-copied .copy__check-bar--long { transform: rotate(-52deg) scaleX(1); }
.copy.is-copied .copy__tip { opacity: 1; transform: translate(-50%, -2px); }
}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);
}
.copy {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: 12px;
padding: clamp(12px, 4vw, 16px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.copy__icon {
position: relative;
width: 22px;
height: 22px;
flex: none;
}
.copy__ring {
position: absolute;
inset: -3px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
transform: scale(0.4);
}
.copy__sheet {
position: absolute;
width: 14px;
height: 16px;
border: 2px solid currentColor;
border-radius: 3px;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.copy__sheet--back {
left: 0;
top: 0;
opacity: 0.55;
}
.copy__sheet--front {
left: 5px;
top: 4px;
background: #17141a;
}
.copy__check {
position: absolute;
inset: 0;
opacity: 0;
}
.copy__check-bar {
position: absolute;
height: 3px;
border-radius: 2px;
background: currentColor;
transform-origin: left center;
}
.copy__check-bar--short {
width: 6px;
left: 7px;
top: 13px;
transform: rotate(225deg) scaleX(0);
}
.copy__check-bar--long {
width: 12px;
left: 7px;
top: 13px;
transform: rotate(-52deg) scaleX(0);
}
.copy__tip {
position: absolute;
left: 50%;
bottom: 100%;
transform: translate(-50%, 4px);
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
.copy.is-copied .copy__ring {
animation: ring-pop 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-copied .copy__sheet--front {
opacity: 0;
transform: scale(0.6);
transition-delay: 0s;
}
.copy.is-copied .copy__sheet--back {
opacity: 0;
transform: scale(0.6);
}
.copy.is-copied .copy__check {
animation: check-in 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-copied .copy__check-bar--short {
animation: bar-short 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 90ms;
}
.copy.is-copied .copy__check-bar--long {
animation: bar-long 300ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
animation-delay: 135ms;
}
.copy.is-copied .copy__tip {
animation: tip-float 1.4s cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
.copy.is-demo .copy__ring {
animation: ring-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__sheet {
animation: sheet-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check {
animation: check-in-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check-bar--short {
animation: bar-short-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__check-bar--long {
animation: bar-long-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.copy.is-demo .copy__tip {
animation: tip-float-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ring-pop {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes ring-pop-loop {
0%, 32% {
opacity: 0;
transform: scale(0.4);
}
40%, 78% {
opacity: 1;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes check-in {
to {
opacity: 1;
}
}
@keyframes bar-short {
to {
transform: rotate(225deg) scaleX(1);
}
}
@keyframes bar-long {
to {
transform: rotate(-52deg) scaleX(1);
}
}
@keyframes tip-float {
0% {
opacity: 0;
transform: translate(-50%, 4px);
}
15% {
opacity: 1;
transform: translate(-50%, -2px);
}
85% {
opacity: 1;
transform: translate(-50%, -2px);
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
@keyframes sheet-loop {
0%, 18% {
opacity: 1;
transform: scale(1);
}
35%, 82% {
opacity: 0;
transform: scale(0.6);
}
95%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes check-in-loop {
0%, 32% {
opacity: 0;
}
40%, 78% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
@keyframes bar-short-loop {
0%, 35% {
transform: rotate(225deg) scaleX(0);
}
45%, 75% {
transform: rotate(225deg) scaleX(1);
}
92%, 100% {
transform: rotate(225deg) scaleX(0);
}
}
@keyframes bar-long-loop {
0%, 40% {
transform: rotate(-52deg) scaleX(0);
}
50%, 75% {
transform: rotate(-52deg) scaleX(1);
}
92%, 100% {
transform: rotate(-52deg) scaleX(0);
}
}
@keyframes tip-float-loop {
0%, 15% {
opacity: 0;
transform: translate(-50%, 4px);
}
32% {
opacity: 1;
transform: translate(-50%, -2px);
}
75% {
opacity: 1;
transform: translate(-50%, -2px);
}
90%, 100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
@media (prefers-reduced-motion: reduce) {
.copy__ring, .copy__sheet, .copy__check, .copy__check-bar, .copy__tip {
animation: none !important;
transition: none !important;
}
.copy.is-copied .copy__ring {
opacity: 1;
transform: scale(1);
}
.copy.is-copied .copy__sheet {
opacity: 0;
}
.copy.is-copied .copy__check {
opacity: 1;
}
.copy.is-copied .copy__check-bar--short {
transform: rotate(225deg) scaleX(1);
}
.copy.is-copied .copy__check-bar--long {
transform: rotate(-52deg) scaleX(1);
}
.copy.is-copied .copy__tip {
opacity: 1;
transform: translate(-50%, -2px);
}
}// 실제 클릭에도 같은 상태(.is-copied)로 반응한다. 데모(.is-demo)는 손을 대면 멈춘다.
document.querySelector('.copy').addEventListener('click', function (e) {
var b = e.currentTarget;
b.classList.remove('is-demo');
b.classList.remove('is-copied');
void b.offsetWidth;
b.classList.add('is-copied');
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyDoneButton.module.scss";
export type CopyDoneButtonProps = {
label: string;
tipLabel: string;
value: string;
/** 아이콘 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CopyDoneButton({
label,
tipLabel,
value,
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: CopyDoneButtonProps) {
const [copied, setCopied] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
const handle = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* 클립보드 권한이 없어도 UI 는 그대로 반응한다 */
}
setCopied(true);
};
return (
<button
type="button"
className={copied ? `${styles.copy} ${styles.copied}` : styles.copy}
style={vars}
onClick={handle}
>
<span className={styles.icon} aria-hidden="true">
<span className={styles.ring} />
<span className={`${styles.sheet} ${styles.sheetBack}`} />
<span className={`${styles.sheet} ${styles.sheetFront}`} />
<span className={styles.check}>
<span className={`${styles.bar} ${styles.barShort}`} />
<span className={`${styles.bar} ${styles.barLong}`} />
</span>
</span>
<span>{label}</span>
<span className={styles.tip} role="status">{tipLabel}</span>
</button>
);
}03 · 복사 완료 피드백 — 측정 누적면적 1.494% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.copy.is-copied .copy__sheet--front,
.copy.is-copied .copy__sheet--back {
opacity: 0;
transform: scale(.6);
}
@keyframes tip-float {
0% { opacity: 0; transform: translate(-50%, 4px); }
15% { opacity: 1; transform: translate(-50%, -2px); }
85% { opacity: 1; transform: translate(-50%, -2px); }
100% { opacity: 0; transform: translate(-50%, -10px); }
}
04낙관적 좋아요(즉시+되돌림)
어떤 움직임인가
하트를 누르면 서버 응답을 기다리지 않고 즉시 채워지고 숫자가 오도미터처럼 한 칸 올라갑니다.

<main class="stage">
<button class="like is-demo" type="button" aria-pressed="false" aria-label="좋아요">
<span class="like__icon" aria-hidden="true">
<span class="like__heart like__heart--base"></span>
<span class="like__heart like__heart--fill"></span>
</span>
<span class="like__count" aria-hidden="true">
<span class="like__count-track">
<span class="like__count-num">12</span>
<span class="like__count-num">13</span>
</span>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 하트가 차오르는 시간
$easing: $ease-pop; // 팝업 이징
$color: $subject-blue; // 좋아요 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
@mixin heart-shape {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
}
.like {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: $r-pill;
padding: clamp(12px, 4vw, 16px) clamp(20px, 6vw, 26px);
font-weight: 700;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&__icon { position: relative; width: 22px; height: 20px; flex: none; color: $stage-ink; }
&__heart {
position: absolute; inset: 0;
transform: scale(1);
&::before { @include heart-shape; left: 50%; transform: rotate(-45deg); transform-origin: 0 100%; } // 규칙8: 하트 로브 꼭짓점 조형
&::after { @include heart-shape; left: 0; transform: rotate(45deg); transform-origin: 100% 100%; } // 규칙8: 하트 로브 꼭짓점 조형
}
&__heart--base { opacity: .28; }
&__heart--fill {
color: $color;
opacity: 0;
transform: scale(.5);
transition: opacity $duration $easing, transform $duration $easing;
}
&__count { position: relative; height: 1em; overflow: hidden; }
&__count-track {
display: flex;
flex-direction: column;
transform: translateY(0);
transition: transform $duration $easing;
}
&__count-num { height: 1em; line-height: 1; }
&.is-liked &__heart--fill { opacity: 1; transform: scale(1); }
&.is-liked &__count-track { transform: translateY(-50%); }
&.is-demo &__heart--fill { animation: fill-loop $dur-loop $easing infinite; }
&.is-demo &__count-track { animation: count-loop $dur-loop $easing infinite; }
}
@keyframes fill-loop {
0%, 20% { opacity: 0; transform: scale(.5); }
35%, 60% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(.5); }
}
@keyframes count-loop {
0%, 20% { transform: translateY(0); }
35%, 60% { transform: translateY(-50%); }
80%, 100% { transform: translateY(0); }
}
// ── 접근성: 움직임 줄이기 설정이면 순간 전환만, 롤백 상태는 유지 ────
@media (prefers-reduced-motion: reduce) {
.like__heart--fill, .like__count-track { transition: none; }
.like.is-demo .like__heart--fill,
.like.is-demo .like__count-track { animation: 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);
}
.like {
display: inline-flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
border: 0;
border-radius: 999px;
padding: clamp(12px, 4vw, 16px) clamp(20px, 6vw, 26px);
font-weight: 700;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.like__icon {
position: relative;
width: 22px;
height: 20px;
flex: none;
color: #17141a;
}
.like__heart {
position: absolute;
inset: 0;
transform: scale(1);
}
.like__heart::before {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 50%;
transform: rotate(-45deg);
transform-origin: 0 100%;
}
.like__heart::after {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 80%;
border-radius: 50% 50% 0 0;
background: currentColor;
left: 0;
transform: rotate(45deg);
transform-origin: 100% 100%;
}
.like__heart--base {
opacity: 0.28;
}
.like__heart--fill {
color: #2f6df6;
opacity: 0;
transform: scale(0.5);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like__count {
position: relative;
height: 1em;
overflow: hidden;
}
.like__count-track {
display: flex;
flex-direction: column;
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.like__count-num {
height: 1em;
line-height: 1;
}
.like.is-liked .like__heart--fill {
opacity: 1;
transform: scale(1);
}
.like.is-liked .like__count-track {
transform: translateY(-50%);
}
.like.is-demo .like__heart--fill {
animation: fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.like.is-demo .like__count-track {
animation: count-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes fill-loop {
0%, 20% {
opacity: 0;
transform: scale(0.5);
}
35%, 60% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(0.5);
}
}
@keyframes count-loop {
0%, 20% {
transform: translateY(0);
}
35%, 60% {
transform: translateY(-50%);
}
80%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.like__heart--fill, .like__count-track {
transition: none;
}
.like.is-demo .like__heart--fill,
.like.is-demo .like__count-track {
animation: none !important;
}
}// 실제 클릭: 서버 응답 없이 즉시 좋아요 표시 → 0.8초 뒤 실패했다고 가정하고 되돌린다.
var b = document.querySelector('.like');
b.addEventListener('click', function () {
b.classList.remove('is-demo');
var liked = b.classList.toggle('is-liked');
b.setAttribute('aria-pressed', liked);
if (liked) setTimeout(function () { b.classList.remove('is-liked'); b.setAttribute('aria-pressed', 'false'); }, 800);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OptimisticLikeButton.module.scss";
export type OptimisticLikeButtonProps = {
count: number;
ariaLabel: string;
/** 낙관적 업데이트가 유지되는 시간 — 이후 실패로 가정하고 롤백 (기본 800ms) */
rollbackAfter?: number;
duration?: number;
easing?: string;
color?: string;
/** 실제로는 여기서 서버에 요청을 보내고, 실패하면 되돌린다 */
onToggle?: (liked: boolean) => Promise<boolean>;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function OptimisticLikeButton({
count,
ariaLabel,
rollbackAfter = 800,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
onToggle,
}: OptimisticLikeButtonProps) {
const [liked, setLiked] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const handle = async () => {
setLiked(true); // 서버 응답을 기다리지 않고 즉시 반영
const ok = onToggle ? await onToggle(true) : false;
if (!ok) timer.current = setTimeout(() => setLiked(false), rollbackAfter);
};
return (
<button
type="button"
className={liked ? `${styles.like} ${styles.liked}` : styles.like}
style={vars}
aria-pressed={liked}
aria-label={ariaLabel}
onClick={handle}
>
<span className={styles.icon} aria-hidden="true">
<span className={`${styles.heart} ${styles.base}`} />
<span className={`${styles.heart} ${styles.fill}`} />
</span>
<span className={styles.count} aria-hidden="true">
<span className={styles.track}>
<span className={styles.num}>{count}</span>
<span className={styles.num}>{count + 1}</span>
</span>
</span>
</button>
);
}04 · 낙관적 좋아요(즉시+되돌림) — 측정 누적면적 0.354% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
실패로 가정한 흐름에서는 0.8초 뒤 두 값 모두 원래대로 돌아갑니다.
.like__count { position: relative; height: 1em; overflow: hidden; }
.like__count-track {
display: flex;
flex-direction: column;
transition: transform $duration $easing;
}
.like.is-liked .like__heart--fill { opacity: 1; transform: scale(1); }
.like.is-liked .like__count-track { transform: translateY(-50%); }
05스켈레톤→콘텐츠 크로스페이드
무엇이 보이나
같은 자리에 겹쳐 둔 스켈레톤 레이어와 실제 콘텐츠 레이어가 opacity로 교차합니다.

<main class="stage">
<div class="card is-demo">
<div class="card__layer card__layer--skeleton" aria-hidden="true">
<span class="sk sk--avatar"></span>
<span class="card__col">
<span class="sk sk--title"></span>
<span class="sk sk--line"></span>
<span class="sk sk--line sk--line--short"></span>
</span>
</div>
<div class="card__layer card__layer--content">
<span class="avatar"></span>
<span class="card__col">
<p class="title">제목 텍스트</p>
<p class="body">본문 내용이 여기 표시됩니다</p>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 스켈레톤 → 콘텐츠 전환 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-blue; // 콘텐츠 아바타 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.card {
position: relative;
width: min(230px, 82vw);
height: clamp(110px, 40vw, 130px);
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
}
.card__layer {
position: absolute; inset: 0;
box-sizing: border-box;
padding: clamp(14px, 5vw, 18px);
display: flex;
align-items: flex-start;
gap: 12px;
}
.card__col {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
// ── 스켈레톤 레이어 ──────────────────────────────────
.card__layer--skeleton {
flex-direction: row;
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.sk {
display: block;
border-radius: $r-control;
background: rgba($stage-ink, .12);
}
.sk--avatar { width: 34px; height: 34px; border-radius: 50%; flex: none; }
.sk--title { width: 110px; height: 14px; margin-bottom: 10px; }
.sk--line { display: block; width: 130px; height: 10px; margin-top: 8px; }
.sk--line--short { width: 85px; }
// ── 콘텐츠 레이어 ────────────────────────────────────
.card__layer--content {
flex-direction: row;
align-items: flex-start;
opacity: 0;
transform: scale(.98);
transition: opacity $duration $easing, transform $duration $easing;
}
.avatar {
width: 34px; height: 34px; border-radius: 50%; flex: none;
background: $color;
}
.title { margin: 0 0 6px; color: $stage-ink; font-weight: 700; font-size: clamp(13px, 3.8vw, 15px); line-height: 1.3; font-family: inherit; }
.body { margin: 0; color: rgba($stage-ink, .68); font-weight: 500; font-size: clamp(12px, 3.4vw, 13px); line-height: 1.4; font-family: inherit; }
.card.is-demo {
.card__layer--skeleton { animation: sk-fade $dur-loop $easing infinite; }
.card__layer--content { animation: content-fade $dur-loop $easing infinite; }
}
@keyframes sk-fade {
0%, 12% { opacity: 1; transform: scale(1); }
55%, 100% { opacity: 0; transform: scale(1.02); }
}
@keyframes content-fade {
0%, 12% { opacity: 0; transform: scale(.98); }
55%, 100% { opacity: 1; transform: scale(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 콘텐츠 상태로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.card__layer--skeleton, .card__layer--content { transition: none; }
.card.is-demo .card__layer--skeleton,
.card.is-demo .card__layer--content { animation: none !important; }
.card .card__layer--skeleton { opacity: 0; }
.card .card__layer--content { opacity: 1; transform: scale(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.card {
position: relative;
width: min(230px, 82vw);
height: clamp(110px, 40vw, 130px);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.card__layer {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: clamp(14px, 5vw, 18px);
display: flex;
align-items: flex-start;
gap: 12px;
}
.card__col {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.card__layer--skeleton {
flex-direction: row;
opacity: 1;
transform: scale(1);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.sk {
display: block;
border-radius: 12px;
background: rgba(23, 20, 26, 0.12);
}
.sk--avatar {
width: 34px;
height: 34px;
border-radius: 50%;
flex: none;
}
.sk--title {
width: 110px;
height: 14px;
margin-bottom: 10px;
}
.sk--line {
display: block;
width: 130px;
height: 10px;
margin-top: 8px;
}
.sk--line--short {
width: 85px;
}
.card__layer--content {
flex-direction: row;
align-items: flex-start;
opacity: 0;
transform: scale(0.98);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
flex: none;
background: #2f6df6;
}
.title {
margin: 0 0 6px;
color: #17141a;
font-weight: 700;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.body {
margin: 0;
color: rgba(23, 20, 26, 0.68);
font-weight: 500;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.4;
font-family: inherit;
}
.card.is-demo .card__layer--skeleton {
animation: sk-fade 2s ease-out infinite;
}
.card.is-demo .card__layer--content {
animation: content-fade 2s ease-out infinite;
}
@keyframes sk-fade {
0%, 12% {
opacity: 1;
transform: scale(1);
}
55%, 100% {
opacity: 0;
transform: scale(1.02);
}
}
@keyframes content-fade {
0%, 12% {
opacity: 0;
transform: scale(0.98);
}
55%, 100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.card__layer--skeleton, .card__layer--content {
transition: none;
}
.card.is-demo .card__layer--skeleton,
.card.is-demo .card__layer--content {
animation: none !important;
}
.card .card__layer--skeleton {
opacity: 0;
}
.card .card__layer--content {
opacity: 1;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./SkeletonToContentCard.module.scss";
export type SkeletonToContentCardProps = {
loading: boolean;
title: string;
body: string;
/** 전환 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SkeletonToContentCard({
loading,
title,
body,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
}: SkeletonToContentCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.card} style={vars}>
<div className={styles.layer} style={{ opacity: loading ? 1 : 0 }} aria-hidden="true">
<span className={`${styles.sk} ${styles.avatar}`} />
<div>
<span className={`${styles.sk} ${styles.title}`} />
<span className={`${styles.sk} ${styles.line}`} />
<span className={`${styles.sk} ${styles.lineShort}`} />
</div>
</div>
<div className={styles.layer} style={{ opacity: loading ? 0 : 1, transform: loading ? "scale(.98)" : "scale(1)" }}>
<span className={styles.realAvatar} />
<div>
<p className={styles.realTitle}>{title}</p>
<p className={styles.realBody}>{body}</p>
</div>
</div>
</div>
);
}05 · 스켈레톤→콘텐츠 크로스페이드 — 측정 누적면적 3.401% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
이 마이크로 인터랙션 예시에서 콘텐츠 레이어는 scale(.98→1)도 같이 줘서 자리 잡는 느낌을 살짝 더합니다.
.card { position: relative; height: clamp(110px, 40vw, 130px); }
.card__layer {
position: absolute; inset: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.card__layer--skeleton { opacity: 1; }
.card__layer--content { opacity: 0; transform: scale(.98); }
06빈 상태 아이콘 바운스
화면에서 일어나는 일
목록이 비었을 때 뜨는 트레이 아이콘이 translateY로 위아래를 부드럽게 오갑니다.

<main class="stage">
<div class="empty is-demo">
<span class="empty__icon" aria-hidden="true">
<span class="empty__tray"></span>
<span class="empty__divider"></span>
</span>
<p class="empty__text">검색 결과가 없습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 바운스 한 주기 시간
$easing: $ease-out; // 위아래로 부드럽게
$color: $stage-ink; // 아이콘 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(10px, 3vw, 14px);
color: $color;
&__icon {
position: relative;
width: 52px;
height: 40px;
transform: translateY(0);
}
&__tray {
position: absolute; inset: 0;
border: 3px solid currentColor;
border-radius: $r-control;
}
&__divider {
position: absolute;
left: 3px; right: 3px; top: 13px;
height: 0;
border-top: 3px solid currentColor;
}
&__text {
margin: 0;
font-weight: 700;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.3;
font-family: inherit;
}
&.is-demo &__icon { animation: bounce $duration $easing infinite; }
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
// ── 접근성: 움직임 줄이기 설정이면 정지 ────────────────────
@media (prefers-reduced-motion: reduce) {
.empty.is-demo .empty__icon { animation: none !important; }
}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);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(10px, 3vw, 14px);
color: #17141a;
}
.empty__icon {
position: relative;
width: 52px;
height: 40px;
transform: translateY(0);
}
.empty__tray {
position: absolute;
inset: 0;
border: 3px solid currentColor;
border-radius: 12px;
}
.empty__divider {
position: absolute;
left: 3px;
right: 3px;
top: 13px;
height: 0;
border-top: 3px solid currentColor;
}
.empty__text {
margin: 0;
font-weight: 700;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.3;
font-family: inherit;
}
.empty.is-demo .empty__icon {
animation: bounce 2s ease-out infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@media (prefers-reduced-motion: reduce) {
.empty.is-demo .empty__icon {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./EmptyStateBounce.module.scss";
export type EmptyStateBounceProps = {
text: string;
/** 바운스 한 주기 시간 (기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function EmptyStateBounce({
text,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: EmptyStateBounceProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.empty} style={vars}>
<span className={styles.icon} aria-hidden="true">
<span className={styles.tray} />
<span className={styles.divider} />
</span>
<p className={styles.text}>{text}</p>
</div>
);
}06 · 빈 상태 아이콘 바운스 — 측정 누적면적 1.361% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
텍스트는 움직이지 않고 아이콘만 반복해서 시선을 붙잡습니다.
.empty.is-demo .empty__icon {
animation: bounce $duration $easing infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
07입력 오류 흔들림
어떤 움직임인가
잘못된 값을 제출하면 입력창을 감싼 프레임이 좌우로 짧게 흔들리고, 겹쳐 둔 빨간 테두리 링이 opacity로 나타나며, 아래 에러 문구가 위로 살짝 뜨며 표시됩니다.

<main class="stage">
<div class="field is-demo">
<div class="field__frame">
<input class="field__input" type="email" placeholder="이메일" readonly>
<span class="field__ring" aria-hidden="true"></span>
</div>
<p class="field__msg" role="alert">이메일 형식이 올바르지 않습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 흔들리는 시간
$easing: cubic-bezier(.36, .07, .19, .97); // 흔들림 전용 이징 (요동)
$amount: 6px; // 흔들리는 폭
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.field {
width: min(230px, 82vw);
cursor: pointer;
&__frame { position: relative; }
&__input {
box-sizing: border-box;
width: 100%;
border: 2.5px solid transparent;
border-radius: $r-control;
padding: clamp(10px, 3.4vw, 14px) clamp(12px, 4vw, 16px);
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
pointer-events: none;
}
// 빨간 테두리는 별도 링으로 겹쳐 opacity 만 움직인다 (border-color 는 애니메이션하지 않는다)
&__ring {
position: absolute; inset: 0;
border: 2.5px solid $error;
border-radius: $r-control;
opacity: 0;
pointer-events: none;
}
&__msg {
margin: 8px 0 0;
color: $subject-cream;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(-4px);
transition: opacity $duration $easing, transform $duration $easing;
}
&.is-error &__frame { animation: shake $duration $easing both; }
&.is-error &__ring { opacity: 1; }
&.is-error &__msg { opacity: 1; transform: translateY(0); }
&.is-demo &__frame { animation: shake-loop $dur-loop $easing infinite; }
&.is-demo &__ring { animation: ring-loop $dur-loop $easing infinite; }
&.is-demo &__msg { animation: msg-loop $dur-loop $easing infinite; }
}
@keyframes shake {
10%, 90% { transform: translateX(-$amount * .5); }
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX(-$amount); }
40%, 60% { transform: translateX($amount * .7); }
50% { transform: translateX(-$amount * .7); }
}
@keyframes shake-loop {
0%, 55% { transform: translateX(0); }
60%, 90% { transform: translateX(-$amount * .5); }
65% { transform: translateX($amount); }
70% { transform: translateX(-$amount); }
75% { transform: translateX($amount * .7); }
80% { transform: translateX(-$amount * .7); }
90%, 100% { transform: translateX(0); }
}
@keyframes ring-loop {
0%, 55% { opacity: 0; }
60%, 90% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes msg-loop {
0%, 55% { opacity: 0; transform: translateY(-4px); }
65%, 92% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-4px); }
}
// ── 접근성: 움직임 줄이기 설정이면 흔들림 대신 색으로만 알린다 ────
@media (prefers-reduced-motion: reduce) {
.field__ring, .field__msg { transition: none; }
.field.is-error .field__frame,
.field.is-demo .field__frame { animation: none !important; }
.field.is-demo .field__ring,
.field.is-demo .field__msg { animation: none !important; }
.field.is-error .field__ring { opacity: 1; }
.field.is-error .field__msg { opacity: 1; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
width: min(230px, 82vw);
cursor: pointer;
}
.field__frame {
position: relative;
}
.field__input {
box-sizing: border-box;
width: 100%;
border: 2.5px solid transparent;
border-radius: 12px;
padding: clamp(10px, 3.4vw, 14px) clamp(12px, 4vw, 16px);
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
pointer-events: none;
}
.field__ring {
position: absolute;
inset: 0;
border: 2.5px solid #c2361a;
border-radius: 12px;
opacity: 0;
pointer-events: none;
}
.field__msg {
margin: 8px 0 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(-4px);
transition: opacity 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97), transform 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.field.is-error .field__frame {
animation: shake 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
.field.is-error .field__ring {
opacity: 1;
}
.field.is-error .field__msg {
opacity: 1;
transform: translateY(0);
}
.field.is-demo .field__frame {
animation: shake-loop 2s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
.field.is-demo .field__ring {
animation: ring-loop 2s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
.field.is-demo .field__msg {
animation: msg-loop 2s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
@keyframes shake {
10%, 90% {
transform: translateX(-3px);
}
20%, 80% {
transform: translateX(6px);
}
30%, 70% {
transform: translateX(-6px);
}
40%, 60% {
transform: translateX(4.2px);
}
50% {
transform: translateX(-4.2px);
}
}
@keyframes shake-loop {
0%, 55% {
transform: translateX(0);
}
60%, 90% {
transform: translateX(-3px);
}
65% {
transform: translateX(6px);
}
70% {
transform: translateX(-6px);
}
75% {
transform: translateX(4.2px);
}
80% {
transform: translateX(-4.2px);
}
90%, 100% {
transform: translateX(0);
}
}
@keyframes ring-loop {
0%, 55% {
opacity: 0;
}
60%, 90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes msg-loop {
0%, 55% {
opacity: 0;
transform: translateY(-4px);
}
65%, 92% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.field__ring, .field__msg {
transition: none;
}
.field.is-error .field__frame,
.field.is-demo .field__frame {
animation: none !important;
}
.field.is-demo .field__ring,
.field.is-demo .field__msg {
animation: none !important;
}
.field.is-error .field__ring {
opacity: 1;
}
.field.is-error .field__msg {
opacity: 1;
transform: none;
}
}// 실제 제출 실패 시 이 클래스를 켠다. 데모(.is-demo) 루프는 클릭하면 멈추고 수동 재생으로 바뀐다.
document.querySelector('.field').addEventListener('click', function (e) {
var f = e.currentTarget;
f.classList.remove('is-demo');
f.classList.remove('is-error');
void f.offsetWidth;
f.classList.add('is-error');
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InputErrorShake.module.scss";
export type InputErrorShakeProps = {
error: boolean;
message: string;
duration?: number;
easing?: string;
amount?: number;
/** 실제 input 에 얹을 값 — placeholder·value·onChange 등만 최소로 받는다 */
inputProps?: { placeholder?: string; value?: string; type?: string; onChange?: (e: { target: { value: string } }) => void };
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--amount"?: string };
export default function InputErrorShake({
error,
message,
duration = 600,
easing = "cubic-bezier(.36,.07,.19,.97)",
amount = 6,
inputProps,
}: InputErrorShakeProps) {
const [shaking, setShaking] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--amount": `${amount}px` };
useEffect(() => {
if (!error) return;
setShaking(true);
const t = setTimeout(() => setShaking(false), duration);
return () => clearTimeout(t);
}, [error, duration]);
return (
<div className={styles.field} style={vars}>
<div className={shaking ? `${styles.frame} ${styles.shaking}` : styles.frame}>
<input className={styles.input} {...inputProps} />
<span className={error ? `${styles.ring} ${styles.ringOn}` : styles.ring} aria-hidden="true" />
</div>
<p className={error ? `${styles.msg} ${styles.msgOn}` : styles.msg} role="alert">{message}</p>
</div>
);
}07 · 입력 오류 흔들림 — 측정 누적면적 3.311% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.field__ring {
position: absolute; inset: 0;
border: 2.5px solid $error;
opacity: 0;
}
.field.is-error .field__frame { animation: shake $duration $easing both; }
.field.is-error .field__ring { opacity: 1; }
@keyframes shake {
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX(-$amount); }
50% { transform: translateX(-$amount * .7); }
}
08탭 인디케이터 슬라이드
무엇이 보이나
탭 세 칸을 grid-template-columns: repeat(3, 1fr)로 똑같은 폭으로 맞춰 두면, 인디케이터는 고정 폭에서 translateX를 100% 배수로만 옮기면 됩니다.

<main class="stage">
<nav class="tabs is-demo" role="tablist">
<button class="tabs__item is-active" type="button" role="tab" aria-selected="true">개요</button>
<button class="tabs__item" type="button" role="tab" aria-selected="false">설정</button>
<button class="tabs__item" type="button" role="tab" aria-selected="false">결제</button>
<span class="tabs__indicator" aria-hidden="true"></span>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 인디케이터가 옮겨가는 시간
$easing: $ease-spring; // 이동 곡선
$color: $subject-blue; // 인디케이터·활성 탭 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.tabs {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr); // 세 칸을 똑같은 폭으로 — 그래야 translateX 배수만으로 옮겨간다
width: min(240px, 84vw);
background: $subject-cream;
border-radius: $r-control;
padding: 4px;
--i: 0; // 실제 클릭이 여기에 인덱스를 써 넣는다
&__item {
position: relative;
z-index: 1;
border: 0;
background: transparent;
padding: clamp(9px, 3vw, 12px) 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba($stage-ink, .72); // 흐린 글자는 알파가 아니라 색으로 — .72 가 크림 면 위 6.9:1
cursor: pointer;
&.is-active { color: $subject-blue-deep; } // 색은 상태값 — 트랜지션 없이 즉시. 크림 면 위 파란 글자는 deep(5.98:1)
}
&__indicator {
position: absolute;
left: 4px; bottom: 4px;
width: calc((100% - 8px) / 3);
height: 3px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $duration $easing;
}
&.is-demo &__indicator { animation: tab-loop $dur-loop $easing infinite; }
}
@keyframes tab-loop {
0%, 20% { transform: translateX(0%); }
33%, 53% { transform: translateX(100%); }
66%, 86% { transform: translateX(200%); }
100% { transform: translateX(0%); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 전환, 첫 탭에 고정 ────────
@media (prefers-reduced-motion: reduce) {
.tabs__indicator { transition: none; }
.tabs.is-demo .tabs__indicator { animation: 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);
}
.tabs {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
width: min(240px, 84vw);
background: #fff7e6;
border-radius: 12px;
padding: 4px;
--i: 0;
}
.tabs__item {
position: relative;
z-index: 1;
border: 0;
background: transparent;
padding: clamp(9px, 3vw, 12px) 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
cursor: pointer;
}
.tabs__item.is-active {
color: #0a4fe8;
}
.tabs__indicator {
position: absolute;
left: 4px;
bottom: 4px;
width: calc((100% - 8px) / 3);
height: 3px;
border-radius: 999px;
background: #2f6df6;
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tabs.is-demo .tabs__indicator {
animation: tab-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tab-loop {
0%, 20% {
transform: translateX(0%);
}
33%, 53% {
transform: translateX(100%);
}
66%, 86% {
transform: translateX(200%);
}
100% {
transform: translateX(0%);
}
}
@media (prefers-reduced-motion: reduce) {
.tabs__indicator {
transition: none;
}
.tabs.is-demo .tabs__indicator {
animation: none !important;
}
}// 실제 클릭: 누른 탭으로 인디케이터가 옮겨간다. 데모(.is-demo) 루프는 클릭하면 멈춘다.
var tabs = document.querySelectorAll('.tabs__item');
var nav = document.querySelector('.tabs');
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
nav.classList.remove('is-demo');
tabs.forEach(function (x) { x.classList.remove('is-active'); x.setAttribute('aria-selected', 'false'); });
t.classList.add('is-active');
t.setAttribute('aria-selected', 'true');
nav.style.setProperty('--i', i);
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabIndicatorSlide.module.scss";
export type TabIndicatorSlideProps = {
tabs: string[];
/** 인디케이터가 옮겨가는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--i"?: number };
export default function TabIndicatorSlide({
tabs,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TabIndicatorSlideProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--i": active };
return (
<nav className={styles.tabs} role="tablist" style={{ ...vars, ["--color" as string]: color }}>
{tabs.map((label, i) => (
<button
key={label}
type="button"
role="tab"
aria-selected={i === active}
className={i === active ? `${styles.item} ${styles.active}` : styles.item}
onClick={() => setActive(i)}
>
{label}
</button>
))}
<span className={styles.indicator} style={{ width: `calc((100% - 8px) / ${tabs.length})` }} aria-hidden="true" />
</nav>
);
}08 · 탭 인디케이터 슬라이드 — 측정 누적면적 0.5% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
폭 자체를 다시 재는 스크립트가 필요 없습니다.
.tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tabs__indicator {
width: calc((100% - 8px) / 3);
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $duration $easing;
}
09토스트 쌓임
화면에서 일어나는 일
토스트 세 개가 순서대로 아래에서 올라와 쌓입니다.

<main class="stage">
<div class="toasts">
<button class="toasts__trigger" type="button">알림 보내기</button>
<div class="toasts__list is-demo" aria-live="polite">
<div class="toast toast--1">저장되었습니다</div>
<div class="toast toast--2">업로드 완료</div>
<div class="toast toast--3">새 메시지 도착</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 개가 다 쌓이는 한 주기
$easing: $ease-spring; // 밀려나는 곡선
$color: $subject-blue; // 새 토스트 강조색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.toasts {
display: flex;
flex-direction: column-reverse; // 트리거를 아래에 둬서 위로 쌓이는 스택과 안 겹친다
align-items: center;
gap: clamp(14px, 4vw, 20px);
}
.toasts__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(10px, 3.4vw, 14px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
cursor: pointer;
}
.toasts__list {
position: relative;
width: min(210px, 78vw);
height: 118px; // 슬롯 하나(34px) + 두 칸 밀려 올라갈 여유(42px × 2)
}
.toast {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: 9px 14px;
border-radius: $r-control;
background: $subject-cream;
color: $stage-ink;
font-weight: 600;
font-size: clamp(11.5px, 3.2vw, 13px);
line-height: 1.2;
font-family: inherit;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(16px);
}
.toast--3 { background: $color; color: $subject-cream; } // 가장 새 토스트만 강조색
.toasts__list.is-demo {
.toast--1 { animation: slot-1 $duration $easing infinite; }
.toast--2 { animation: slot-2 $duration $easing infinite; }
.toast--3 { animation: slot-3 $duration $easing infinite; }
}
.toasts__list.is-replaying {
.toast--1 { animation: slot-1 $duration $easing 1; }
.toast--2 { animation: slot-2 $duration $easing 1; }
.toast--3 { animation: slot-3 $duration $easing 1; }
}
// 1번(가장 오래됨): 맨 처음 슬롯0 에 등장 → 2번이 오면 슬롯1 로, 3번이 오면 슬롯2 로 밀려 올라간다
@keyframes slot-1 {
0%, 4% { opacity: 0; transform: translateY(16px) scale(1); }
12%, 38% { opacity: 1; transform: translateY(0) scale(1); }
50%, 68% { opacity: .88; transform: translateY(-42px) scale(.96); }
80%, 92% { opacity: .7; transform: translateY(-84px) scale(.92); }
98%, 100% { opacity: 0; transform: translateY(-84px) scale(.92); }
}
// 2번: 슬롯0 에 늦게 등장 → 3번이 오면 슬롯1 로 밀려 올라간다
@keyframes slot-2 {
0%, 40% { opacity: 0; transform: translateY(16px) scale(1); }
50%, 68% { opacity: 1; transform: translateY(0) scale(1); }
80%, 92% { opacity: .88; transform: translateY(-42px) scale(.96); }
98%, 100% { opacity: 0; transform: translateY(-42px) scale(.96); }
}
// 3번(가장 새 토스트): 가장 늦게 슬롯0 에 등장해 끝까지 자리를 지킨다
@keyframes slot-3 {
0%, 70% { opacity: 0; transform: translateY(16px) scale(1); }
80%, 92% { opacity: 1; transform: translateY(0) scale(1); }
98%, 100% { opacity: 0; transform: translateY(0) scale(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 스택 상태로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.toasts__list.is-demo .toast,
.toasts__list.is-replaying .toast { animation: none !important; }
.toast--1 { opacity: .7; transform: translateY(-84px); }
.toast--2 { opacity: .88; transform: translateY(-42px); }
.toast--3 { opacity: 1; 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.toasts {
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: clamp(14px, 4vw, 20px);
}
.toasts__trigger {
border: 0;
border-radius: 12px;
padding: clamp(10px, 3.4vw, 14px) clamp(18px, 5.6vw, 24px);
font-weight: 700;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
.toasts__list {
position: relative;
width: min(210px, 78vw);
height: 118px;
}
.toast {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
padding: 9px 14px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: clamp(11.5px, 3.2vw, 13px);
line-height: 1.2;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(16px);
}
.toast--3 {
background: #2f6df6;
color: #fff7e6;
}
.toasts__list.is-demo .toast--1 {
animation: slot-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.toasts__list.is-demo .toast--2 {
animation: slot-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.toasts__list.is-demo .toast--3 {
animation: slot-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.toasts__list.is-replaying .toast--1 {
animation: slot-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) 1;
}
.toasts__list.is-replaying .toast--2 {
animation: slot-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) 1;
}
.toasts__list.is-replaying .toast--3 {
animation: slot-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) 1;
}
@keyframes slot-1 {
0%, 4% {
opacity: 0;
transform: translateY(16px) scale(1);
}
12%, 38% {
opacity: 1;
transform: translateY(0) scale(1);
}
50%, 68% {
opacity: 0.88;
transform: translateY(-42px) scale(0.96);
}
80%, 92% {
opacity: 0.7;
transform: translateY(-84px) scale(0.92);
}
98%, 100% {
opacity: 0;
transform: translateY(-84px) scale(0.92);
}
}
@keyframes slot-2 {
0%, 40% {
opacity: 0;
transform: translateY(16px) scale(1);
}
50%, 68% {
opacity: 1;
transform: translateY(0) scale(1);
}
80%, 92% {
opacity: 0.88;
transform: translateY(-42px) scale(0.96);
}
98%, 100% {
opacity: 0;
transform: translateY(-42px) scale(0.96);
}
}
@keyframes slot-3 {
0%, 70% {
opacity: 0;
transform: translateY(16px) scale(1);
}
80%, 92% {
opacity: 1;
transform: translateY(0) scale(1);
}
98%, 100% {
opacity: 0;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.toasts__list.is-demo .toast,
.toasts__list.is-replaying .toast {
animation: none !important;
}
.toast--1 {
opacity: 0.7;
transform: translateY(-84px);
}
.toast--2 {
opacity: 0.88;
transform: translateY(-42px);
}
.toast--3 {
opacity: 1;
transform: translateY(0);
}
}// 실제 클릭 한 번마다 토스트를 하나씩 다시 쌓는다. 데모(.is-demo) 자동 루프는 손을 대면 멈춘다.
var list = document.querySelector('.toasts__list');
document.querySelector('.toasts__trigger').addEventListener('click', function () {
list.classList.remove('is-demo');
list.classList.remove('is-replaying');
void list.offsetWidth;
list.classList.add('is-replaying');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ToastStack.module.scss";
export type ToastStackProps = {
messages: string[];
triggerLabel: string;
/** 세 개가 다 쌓이는 시간 (기본 260ms 씩 순차) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ToastStack({
messages,
triggerLabel,
duration = 260,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ToastStackProps) {
const [visible, setVisible] = useState<number[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const push = () => {
setVisible([]);
messages.forEach((_, i) => {
setTimeout(() => setVisible((v) => [...v, i]), i * duration);
});
};
return (
<div className={styles.toasts} style={vars}>
<button type="button" className={styles.trigger} onClick={push}>{triggerLabel}</button>
<div className={styles.list} aria-live="polite">
{messages.map((msg, i) => {
const shown = visible.includes(i);
const depth = visible.length - 1 - i; // 도착 순서가 곧 인덱스 순서라 뺄셈만으로 깊이가 나온다
return (
<div
key={msg}
className={i === messages.length - 1 ? `${styles.toast} ${styles.newest}` : styles.toast}
style={{
opacity: shown ? Math.max(1 - depth * .12, 0) : 0,
transform: shown ? `translateY(${-depth * 42}px) scale(${1 - depth * .04})` : "translateY(16px) scale(1)",
}}
>
{msg}
</div>
);
})}
</div>
</div>
);
}09 · 토스트 쌓임 — 측정 누적면적 19.122% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
새 토스트가 뜰 때마다 기존 토스트는 고정된 슬롯 높이만큼 translateY로 밀려 올라가며 scale도 함께 줄어듭니다.
.toasts__list.is-demo .toast--1 {
animation: slot-1 $duration $easing infinite;
}
@keyframes slot-1 {
12%, 38% { opacity: 1; transform: translateY(0) scale(1); }
50%, 68% { opacity: .88; transform: translateY(-42px) scale(.96); }
80%, 92% { opacity: .7; transform: translateY(-84px) scale(.92); }
}
어디서 깨지는가 — 함정 하나
겹친 레이어와 부모 높이
05의 스켈레톤·콘텐츠 크로스페이드는 두 레이어를 position: absolute로 겹쳐 두는 방식이라, 부모 .card에 height를 직접 정해 주지 않으면 자식이 없는 것처럼 계산돼 카드가 0으로 접힙니다. absolute 요소는 문서 흐름에서 빠지므로 부모 높이에 기여하지 못한다는 점을 MDN의 CSS 위치 지정 레이아웃 문서가 설명합니다. 눌림·체크 같은 반응에 재질까지 얹고 싶어지면 스큐어모피즘 디자인 랜딩 키트가 01의 스케일을 질감 있는 부품으로 확장합니다. 09의 토스트 스택도 비슷한 값을 미리 정해 두는 방식이라, 슬롯 높이(42px)를 코드에 고정해 뒀습니다. 언어팩을 입혀 글자가 길어지는 en·ja 판에서는 문구가 이 높이를 넘지 않도록 clamp()로 글자 크기를 줄여 확인했고, 이 값이 반영된 아홉 개 파일의 압축 비밀번호는 dawuywyc이고, 아래 버튼으로 받을 수 있습니다.
체크 아이콘을 SVG 로
02와 03의 체크 아이콘은 SVG 대신 border와 회전한 div로 그렸습니다. 아이콘 크기를 바꾸면 rotate(-52deg)처럼 손으로 맞춘 각도도 다시 계산해야 하는데, 이건 SVG path를 쓰면 안 생기는 수고입니다. 크기를 자주 바꿀 아이콘이라면 처음부터 SVG로 그리는 편이 낫습니다.
접근성
동작 줄이기를 켰을 때
아홉 개 전부 prefers-reduced-motion: reduce에서 반복 애니메이션을 끕니다. 다만 상태 전환 자체는 지우지 않는 게 원칙이라, 04의 좋아요 표시나 07의 에러 링처럼 값이 바뀌었다는 사실은 트랜지션 없이 즉시 남습니다. 07의 입력 필드는 role="alert"로 에러 문구를 스크린 리더에 바로 읽어 주고, 04의 좋아요 버튼은 aria-pressed로 지금 상태를 알립니다. 08의 탭은 role="tab"과 aria-selected를 함께 씁니다. 어떤 기준으로 이 설정이 켜지는지는 MDN의 @media prefers-reduced-motion 문서에서 확인할 수 있습니다.
@media (prefers-reduced-motion: reduce) {
.field__ring, .field__msg { transition: none; }
.field.is-demo .field__frame { animation: none !important; }
}
다른 CSS 애니메이션 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
마이크로 인터랙션 ui와 일반 CSS 애니메이션은 뭐가 다릅니까?
마이크로 인터랙션은 배너 등장처럼 장식이 아니라 클릭·저장·오류처럼 사용자가 한 행동에 대한 응답입니다. 그래서 지속 시간이 짧고(220~600ms), 실제 상태 변화와 묶여 있어야 합니다. 이 글의 아홉 개는 전부 실제 클릭이나 입력에 반응하도록 짠 이유가 여기 있습니다. 클릭에 응답하는 대표 부품인 체크박스와 라디오만 모은 CSS 체크박스 커스텀 라디오 애니메이션도 같은 원칙으로 묶여 있습니다.
04의 낙관적 업데이트는 실제 서버 요청과 어떻게 연결합니까?
React 판의 onToggle 같은 콜백에 실제 요청을 넣고, 성공하면 true를 돌려주면 됩니다. 실패하거나 콜백이 없으면 정해 둔 시간(기본 800ms) 뒤 화면이 자동으로 원래 값으로 돌아갑니다. zip의 react/ 폴더에 전체 로직이 있습니다.
아홉 개를 전부 한 페이지에 같이 써도 됩니까?
네, 흔한 조합입니다. 다만 08처럼 .is-demo 클래스로 자동 반복시켜 둔 요소는 실제 화면에서는 사용자의 진짜 상태(클릭·오류 여부)로만 움직이게 그 클래스를 지우고 시작해야 합니다. zip의 vanilla/ 폴더 각 파일 안 스크립트가 그 처리를 그대로 담고 있습니다.









