マイクロインタラクション CSS 9選 — 一行でコピペOK
マイクロインタラクション css は、ボタンを押す・保存する・タブを切り替える一つの操作に、画面がひと呼吸だけ反応して見せる小さなアニメーションです。この記事では9種類を選び、transitionと@keyframesだけで実装しました。グリッドの9つはiframeの中で最初から動いていて、クリックでは止められません。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ボタンが押し込まれる
- 02 保存チェックアニメーション
- 03 コピー完了フィードバック
- 04 楽観的いいね(即時+ロールバック)
- 05 スケルトン→本文クロスフェード
- 06 空状態アイコンのバウンス
- 07 入力エラーで揺れる
- 08 タブインジケータースライド
- 09 トーストの積み重ね
並び順は派手さの順ではなく、反応がどこで完結するかです。まずボタン自身の見た目だけで完結する01〜03(押し込み・モーフ・アイコンスワップ)、次にサーバーの応答を待たず画面を先に書き換える04〜06、最後に要素の外側、入力欄の枠やタブの下線、通知の並びまで動く07〜09という順です。04〜06はAnimista・uiverseのようなライブラリではあまり見ない組み合わせを狙ったアイデア型として選びました。 状態の切り替えを画面全体へ広げるスイッチはCSS ダークモード 切り替え アニメーション9選が担い、操作の脇に短い説明を浮かべる向きはCSS ツールチップ 吹き出しアニメーション9選です。CSSだけで完結させるため、04のJavaScriptはis-likedというクラス名を付け外しするだけで、色や数値そのものは一行も触れていません。
01ボタンが押し込まれる
どんな動きか
ボタンを押すとスケールが0.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
マイクロインタラクション アニメーションの使いどころ
押した瞬間に影も薄くなるため、実際にクリックが受け付けられたという触覚に近い感覚を画面だけで伝えられます。送信ボタンやアイコンボタン、カード全体をクリック領域にした場合に向いています。
.btn {
transform: scale(1);
box-shadow: $shadow-raised;
transition: transform $duration $easing, box-shadow $duration $easing;
&:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
}
@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; }
}
02保存アイコンがチェックに変わる
何が起きるか
保存ボタンを押すとフロッピー形状のアイコンがopacityとscaleだけで姿を消し、入れ替わりにチェックの2本の線が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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
背景の丸いバッジも同時にポップして色でも完了を強調します。フォームの自動保存ボタンやメモアプリの保存状態表示に向いています。
.save__check-bar {
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
}
.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); }
}
@keyframes disk-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(.5); }
}
03コピー完了フィードバック
動きの内容
コピーアイコンを押すと二重に重なった用紙のアイコンが縮んで消え、同じ場所にチェックの線が現れます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
同時にボタンの上へ小さなツールチップが浮かび上がり、1.2秒表示されてから上へフェードアウトします。コードブロックのコピーボタンやAPIキーのコピーボタンに向いています。
.copy.is-copied .copy__sheet--front {
opacity: 0;
transform: scale(.6);
}
.copy.is-copied .copy__tip {
animation: tip-float 1.4s $easing forwards;
}
@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楽観的いいね(即時+ロールバック)
どんな動きか
ハートを押すとサーバーの応答を待たずに即座に赤く塗りつぶされ、数字のカウントも1つ繰り上がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
このデモは失敗を仮定したループになっていて、赤くなってから0.8秒後にはハートも数字も元の状態へロールバックします。SNSのいいねボタンやブックマークトグルに向いています。
var b = document.querySelector('.like');
b.addEventListener('click', function () {
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);
}
});
05スケルトン→本文クロスフェード
何が起きるか
グレーのスケルトンブロックと実際のテキスト・アバター画像が同じ位置に重なっていて、読み込みが終わるとopacityとscaleが入れ替わってクロスフェードします。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
位置がずれないため、読み込み完了の瞬間にレイアウトが跳ねません。カード一覧の読み込みやプロフィールページの初期表示に向いています。
.card__layer--skeleton {
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.card__layer--content {
opacity: 0;
transform: scale(.98);
transition: opacity $duration $easing, transform $duration $easing;
}
.card.is-demo .card__layer--skeleton { animation: sk-fade $dur-loop $easing infinite; }
.card.is-demo .card__layer--content { animation: content-fade $dur-loop $easing infinite; }
@keyframes content-fade {
0%, 12% { opacity: 0; transform: scale(.98); }
55%, 100% { opacity: 1; transform: scale(1); }
}
06空状態アイコンのバウンス
動きの内容
検索結果や一覧が空のときに表示されるトレイのアイコンが、上下にゆっくりバウンスし続けます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
動きが止まらないことで、この空白が読み込みエラーではなく意図された状態だと伝えます。検索結果なし画面や空のカート画面に向いています。
.empty__icon {
transform: translateY(0);
}
.empty.is-demo .empty__icon {
animation: bounce $duration $easing infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
07入力エラーで揺れる
どんな動きか
不正な値を送信すると入力欄の枠に赤いリングが重なって現れ、枠全体が左右に短く揺れます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
揺れの前後で速度を変えることで、機械的な単純往復ではなく実際に振動しているような手触りになります。ログインフォームのパスワード欄や決済フォームのカード番号入力に向いています。
.field__ring {
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 {
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); }
}
08タブインジケータースライド
何が起きるか
タブをクリックするとカスタムプロパティ--iにインデックスが書き込まれ、下線のインジケーターがtranslateXで前のタブから次のタブの位置まで滑ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
MDNのCSSカスタムプロパティの解説にあるとおりモダンブラウザ全般で使える仕組みですが、対応していない古いブラウザでは--iが反映されず、インジケーターは常に最初のタブの位置のまま動きません。設定ページのタブやダッシュボードのカテゴリタブに向いています。
.tabs {
--i: 0;
}
.tabs__indicator {
width: calc((100% - 8px) / 3);
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $duration $easing;
}
09トーストの積み重ね
動きの内容
新しいトーストが出るたびに、既存のトーストが1段ずつ上へ押し出されながらわずかに縮み、opacityも少しずつ下がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
一番新しいトーストだけ背景色を変えているため、複数の通知が重なっても今どれが最新かひと目でわかります。保存完了通知や複数ファイルアップロードの進捗通知に向いています。
.toast {
opacity: 0;
transform: translateY(16px);
}
.toast--3 { background: $color; color: $subject-cream; }
@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); }
}
どこで崩れるか — 落とし穴
連打でロールバックが二重に走る
9つとも状態の変化をtransformとopacityだけで表していて、位置やサイズの数値を直接書き換えるコードは使っていません。1つ目の落とし穴は04を組み立てているときに踏んだもので、ハートを連打すると0.8秒後のロールバックが二重に走り、赤色と数字が一瞬でちらついて見えました。setTimeoutを呼ぶ前に既存のタイマーをclearTimeoutで消してから、この二重発火は直りました。 押し込みやチェックに質感まで足したくなったら、スキューモーフィズムデザインの質感パーツ9選が01のスケールを触れる部品へ広げます。
タブ幅がそろわない
2つ目は08のタブを作ったときのことです。3つのタブの文字数を揃えずに試すと、それぞれの幅が微妙にずれてcalc((100% - 8px) / 3)で計算した位置とインジケーターの実際の位置が合わなくなり、下線がタブの真下からはみ出して止まって見えました。grid-template-columnsで3列を強制的に同じ幅にしてから、この崩れは直りました。修正済みの9ファイルを開くには dawuywyc という文字列が必要です。下のカードからzipを取ってください。
コンテナの高さが伸び縮みする
3つ目は09のトーストを組み立てたときのことです。リストの高さを固定していない状態で試すと、新しいトーストが増えるたびにコンテナ自体の高さが伸び縮みし、下にある要素がそのたびに位置を変えてがたつきました。heightを固定してからは、コンテナの外側は動かず内側のトーストだけが積み重なるようになりました。
アクセシビリティ
動きを減らす設定のとき
9つとも prefers-reduced-motion: reduce で動きが止まりますが、要素そのものは消えません。01のボタンは影も含めて:active時の見た目のまま止まり、04のハートと数字は最終的な色と値を保った状態でロールバックのアニメーションだけを省略します。08のタブインジケーターはtransitionだけを切り、クリックした瞬間に新しい位置へ即座に移動します。
@media (prefers-reduced-motion: reduce) {
.btn, .save__ring, .save__disk, .save__check-bar,
.copy__ring, .copy__sheet, .copy__check-bar, .copy__tip,
.like__heart--fill, .like__count-track,
.card__layer--skeleton, .card__layer--content,
.empty__icon, .field__frame, .field__ring, .field__msg,
.tabs__indicator, .toast {
transition: none !important;
animation: none !important;
}
}
他の マイクロインタラクション 事例やCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
マイクロインタラクション サンプルの動きはモバイルのタップでも同じように出ますか?
はい、01の:active擬似クラスと04・07・08のJavaScriptイベントは、どちらもタッチデバイスのpointerdownやclickでも同じように発火します。ただしiOSのSafariでは:activeがタップ直後の一瞬しか適用されないことがあり(MDN の :active の解説にタッチ端末での挙動の注記があります)、その場合は04や08のようにpointerdownでクラスを付け外しする方式のほうが安定して動きます。zipのvanilla/フォルダの各index.htmlは、この違いを踏まえてボタン系(01・02・03・04)にクリックイベントを使っています。
9個の状態はReactではどう管理しますか?
zipのreact/フォルダの各コンポーネントは、これまでと同じクラス名をdiv要素やbutton要素に付けているだけで、動きの速さと曲線はCSSのanimationとtransitionが担います。04のいいねボタンと07の入力エラー、09のトースト一覧だけはuseStateで状態(liked・error・トースト配列)を持ち、そのstateに応じてis-likedやis-errorに相当するクラス名を付け外しします。08のタブは選択中のインデックスをstateに持ち、インラインスタイルではなく--iカスタムプロパティへ渡しています。 クリックに応える定番の部品であるチェックボックスとラジオだけを集めたラジオボタン チェックボックス css カスタムアニメーション9選も、同じ原則でまとめてあります。
04の「先に画面を更新してあとで戻す」という考え方はどんな場面に向いていますか?
04の考え方は、サーバーの応答を待つ間もユーザーの操作を止めたくない場面に向いています。いいねやブックマークのように、失敗しても押し直せば済むうえ見た目の被害が小さい操作に向いていて、送金や削除のように取り返しがつかない操作には使うべきではありません。今回のデモは失敗を仮定したロールバックのみを再現していますが、実際のアプリでは成功した場合にサーバーからの返り値で数字を上書きする処理が別途必要です。









