Tailwind 애니메이션 유틸 9종(v4) — 한 줄 복붙으로
Tailwind 애니메이션은 tailwind.config.js가 아니라 CSS 파일 안 @theme 블록에 --animate- 변수와 @keyframes를 같이 적어서 만드는 Tailwind v4의 새 방식입니다.
01 tailwind-v4-animation-theme-keyframes
02 tailwind-v4-animation-tw-animate-in
03 tailwind-v4-animation-fade-slide-util
04 tailwind-v4-animation-arbitrary-timeline
06 tailwind-v4-animation-data-state-anim
07 tailwind-v4-animation-motion-safe-guard
08 tailwind-v4-animation-spin-util
09 tailwind-v4-animation-stagger-util
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 @theme 커스텀 키프레임
- 02 tw-animate-css 진입 유틸
- 03 페이드+슬라이드 유틸
- 04 임의값 scroll-timeline
- 05 group-hover 아이콘
- 06 data-state 기반 애니메이션(shadcn)
- 07 motion-safe 가드
- 08 spin 유틸
- 09 스태거 유틸
아홉 개는 인기순이 아니라 Tailwind v4 문법을 배우는 순서로 놓았습니다. 먼저 커스텀 애니메이션을 등록하는 법(01~03)을 익히고, 브라우저가 갈리는 기능을 안전하게 흉내 내는 법(04), 상태에 반응시키는 법(05~06), 접근성 가드(07), 기존 유틸을 확장하는 법(08~09) 순서입니다. tailwind css 애니메이션 클래스 조각만 옮겼고, 완결본과 SCSS 판은 압축 파일 하나에 몰아 뒀습니다. 클래스 문법을 걷어낸 순수 CSS로 같은 결과에 닿는 길은 CSS 배경 애니메이션에서 먼저 걸어 봤고, 문장 한 줄만 도드라지게 하고 싶다면 텍스트 강조 애니메이션이 @theme 문법의 다음 걸음입니다.
01@theme 커스텀 키프레임
tailwind css 4 keyframes 를 CSS 안에 적기
tailwind.config.js 없이 CSS의 @theme 블록 안에 --animate-pop-spin 변수와 @keyframes를 같이 적으면, 그 이름이 곧 animate-pop-spin 유틸 클래스가 됩니다.

<main class="stage">
<span class="badge is-demo">NEW</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
// Tailwind v4 의 @theme { --animate-pop-spin: pop-spin 900ms ...; } 와 같은 값을
// 여기서는 리터럴 ms 로 그대로 재현한다 (이 편의 목적은 Tailwind 유틸 값의 재현이라
// $dur-* 토큰을 쓰지 않는다 — 무대·피사체 색은 토큰 그대로 쓴다. 사실.md 참고).
$duration: 900ms; // @theme --animate-pop-spin 의 900ms
$easing: cubic-bezier(.34, 1.56, .64, 1); // 살짝 오버슈트하는 back-out 곡선
$color: $subject-cream; // 배지 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: $color;
color: $subject-ink;
font-weight: 700;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
opacity: 0;
transform: scale(.5) rotate(-8deg);
}
// 데모: 등장 → 유지 → 사라짐을 2초 주기로 반복 (실사용은 .is-appear 로 한 번만)
.badge.is-demo {
animation: pop-spin-loop 2s $easing infinite;
}
@keyframes pop-spin-loop {
0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
20% { opacity: 1; transform: scale(1.08) rotate(3deg); }
32% { opacity: 1; transform: scale(1) rotate(0deg); }
70% { opacity: 1; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(.5) rotate(-8deg); }
}
// 실사용: 한 번만 재생하는 진입 클래스 (JS 가 등장 시점에 건다)
.badge.is-appear {
animation: pop-spin $duration $easing both;
}
@keyframes pop-spin {
0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
60% { opacity: 1; transform: scale(1.08) rotate(3deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 나타나기만 ──────
@media (prefers-reduced-motion: reduce) {
.badge.is-demo, .badge.is-appear { animation: none; 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
opacity: 0;
transform: scale(0.5) rotate(-8deg);
}
.badge.is-demo {
animation: pop-spin-loop 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
@keyframes pop-spin-loop {
0% {
opacity: 0;
transform: scale(0.5) rotate(-8deg);
}
20% {
opacity: 1;
transform: scale(1.08) rotate(3deg);
}
32% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
70% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.5) rotate(-8deg);
}
}
.badge.is-appear {
animation: pop-spin 900ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes pop-spin {
0% {
opacity: 0;
transform: scale(0.5) rotate(-8deg);
}
60% {
opacity: 1;
transform: scale(1.08) rotate(3deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.badge.is-demo, .badge.is-appear {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./ThemeKeyframesBadge.module.scss";
export type ThemeKeyframesBadgeProps = {
label?: string;
/** @theme --animate-pop-spin 의 지속 시간, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ThemeKeyframesBadge({
label = "NEW",
duration = 900,
easing = "cubic-bezier(.34,1.56,.64,1)",
color = "#fff7e6",
}: ThemeKeyframesBadgeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.badge} style={vars}>
{label}
</span>
);
}01 · @theme 커스텀 키프레임 — 측정 누적면적 2.196% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
배지 등장 애니메이션에
배지가 회전하며 튀어나오는 등장 애니메이션에 씁니다.
<style>
@theme {
--animate-pop-spin: pop-spin 900ms cubic-bezier(.34,1.56,.64,1) both;
@keyframes pop-spin {
0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
60% { opacity: 1; transform: scale(1.08) rotate(3deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
}
</style>
<span class="animate-pop-spin motion-reduce:animate-none rounded-full bg-[#fff7e6] px-4 py-2 font-bold">NEW</span>
02tw-animate-css 진입 유틸
플러그인 조합 클래스 하나로
tw-animate-css 플러그인의 animate-in fade-in slide-in-from-top-8 duration-500 조합을 그대로 쓰면, 카드가 위 32px에서 투명하게 시작해 제자리로 내려오며 선명해집니다.

<main class="stage">
<div class="card is-demo">
<p class="card__title">배포 완료</p>
<p class="card__sub">방금 올라간 새 버전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
// Tailwind 「animate-in fade-in slide-in-from-top-8 duration-500」 의 500ms 를
// 리터럴로 재현 (이 편은 Tailwind 유틸 값을 그대로 옮기는 게 목적 — 사실.md 참고).
$duration: 500ms;
$easing: ease-out;
$color: $subject-cream;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.card {
box-sizing: border-box;
width: min(260px, 84vw);
padding: $sp-6 $sp-6;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-32px); // slide-in-from-top-8 = spacing 8 × 4px = 32px
}
.card__title { margin: 0 0 $sp-1; color: $color; font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1.3; font-family: inherit; }
.card__sub { margin: 0; color: rgba($subject-cream, .7); font-weight: 400; font-size: clamp(13px, 3.4vw, 14px); line-height: 1.4; font-family: inherit; }
// 데모: 카드가 위에서 내려와 멈춰 있다가, 2초마다 다시 위로 리셋해 반복
.card.is-demo {
animation: slide-in-loop 2s $easing infinite;
}
@keyframes slide-in-loop {
0% { opacity: 0; transform: translateY(-32px); }
25% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-32px); }
}
// 실사용: 한 번만 재생 (JS 가 mount 시점에 건다)
.card.is-appear {
animation: slide-in $duration $easing both;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-32px); }
to { opacity: 1; transform: translateY(0); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.card.is-demo, .card.is-appear { animation: none; 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.card {
box-sizing: border-box;
width: min(260px, 84vw);
padding: 24px 24px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-32px);
}
.card__title {
margin: 0 0 4px;
color: #fff7e6;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1.3;
font-family: inherit;
}
.card__sub {
margin: 0;
color: rgba(255, 247, 230, 0.7);
font-weight: 400;
font-size: clamp(13px, 3.4vw, 14px);
line-height: 1.4;
font-family: inherit;
}
.card.is-demo {
animation: slide-in-loop 2s ease-out infinite;
}
@keyframes slide-in-loop {
0% {
opacity: 0;
transform: translateY(-32px);
}
25% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-32px);
}
}
.card.is-appear {
animation: slide-in 500ms ease-out both;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card.is-demo, .card.is-appear {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./TwAnimateInCard.module.scss";
export type TwAnimateInCardProps = {
title?: string;
subtitle?: string;
/** tw-animate-css 의 duration-500 에 대응, ms */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TwAnimateInCard({
title = "Deployed",
subtitle = "The new version just went live",
duration = 500,
easing = "ease-out",
color = "#fff7e6",
}: TwAnimateInCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.card} style={vars}>
<p className={styles.card__title}>{title}</p>
<p className={styles.card__sub}>{subtitle}</p>
</div>
);
}02 · tw-animate-css 진입 유틸 — 측정 누적면적 27.637% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모달과 토스트 첫 등장에
모달이나 토스트의 첫 등장에 씁니다.
npm install -D tw-animate-css
<!-- app.css 에 @import "tw-animate-css"; 한 줄만 추가하면 끝 -->
<div
class="animate-in fade-in slide-in-from-top-8 duration-500
motion-reduce:animate-none
rounded-2xl bg-[#17141a] p-6"
>
<p class="text-[#fff7e6] font-bold">Deployed</p>
<p class="text-[#fff7e6]/70 text-sm">The new version just went live</p>
</div>
03페이드+슬라이드 유틸
변수 하나로 opacity 와 translateY 묶기
플러그인 없이 @theme에 --animate-fade-slide 하나만 정의해 opacity와 translateY를 한 키프레임에 묶으면, 문단이 아래 16px에서 떠오르며 나타나는 유틸 클래스 하나로 끝납니다.

<main class="stage">
<p class="lead is-demo">한 줄로 끝나는<br>Tailwind 애니메이션</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 400ms;
$easing: ease-out;
$color: $subject-cream;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.lead {
margin: 0;
max-width: min(300px, 88vw);
color: $color;
text-align: center;
font-weight: 700;
font-size: clamp(18px, 5.2vw, 24px);
line-height: 1.35;
font-family: inherit;
opacity: 0;
transform: translateY(16px);
}
// 데모: --animate-fade-slide 하나로 문단이 떠오르는 걸 2초마다 반복
.lead.is-demo {
animation: fade-slide-loop 2s $easing infinite;
}
@keyframes fade-slide-loop {
0% { opacity: 0; transform: translateY(16px); }
22% { opacity: 1; transform: translateY(0); }
82% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(16px); }
}
// 실사용: 한 번만
.lead.is-appear {
animation: fade-slide $duration $easing both;
}
@keyframes fade-slide {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.lead.is-demo, .lead.is-appear { animation: none; 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);
}
.lead {
margin: 0;
max-width: min(300px, 88vw);
color: #fff7e6;
text-align: center;
font-weight: 700;
font-size: clamp(18px, 5.2vw, 24px);
line-height: 1.35;
font-family: inherit;
opacity: 0;
transform: translateY(16px);
}
.lead.is-demo {
animation: fade-slide-loop 2s ease-out infinite;
}
@keyframes fade-slide-loop {
0% {
opacity: 0;
transform: translateY(16px);
}
22% {
opacity: 1;
transform: translateY(0);
}
82% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(16px);
}
}
.lead.is-appear {
animation: fade-slide 400ms ease-out both;
}
@keyframes fade-slide {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.lead.is-demo, .lead.is-appear {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./FadeSlideLead.module.scss";
export type FadeSlideLeadProps = {
children?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FadeSlideLead({
children = "One line is all it takes",
duration = 400,
easing = "ease-out",
color = "#fff7e6",
}: FadeSlideLeadProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.lead} style={vars}>
{children}
</p>
);
}03 · 페이드+슬라이드 유틸 — 측정 누적면적 6.125% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
히어로 문단 등장에
히어로 문단이나 섹션 타이틀 등장에 씁니다.
<style>
@theme {
--animate-fade-slide: fade-slide 400ms ease-out both;
@keyframes fade-slide {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
}
</style>
<p class="animate-fade-slide text-center font-bold text-2xl">One line is all it takes</p>
04임의값 scroll-timeline
전용 유틸이 없는 속성 붙이기
Tailwind는 scroll-timeline 전용 유틸이 없어 임의 속성 [animation-timeline:scroll()]으로 붙이는데, 브라우저마다 지원이 갈려 이 데모는 같은 진행률을 안전한 루프로 재현합니다.

<main class="stage">
<div class="page">
<p class="page__label">읽은 만큼 채워지는 진행바</p>
<div class="track"><div class="track__fill is-demo"></div></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
// 실제 스크롤에 물리는 animation-timeline:scroll() 은 브라우저마다 지원이 갈려
// (편브리프 표 참고) 데모에서 쓰지 않는다 — 같은 진행률을 2초 루프로 안전하게 재현한다.
$duration: 2000ms;
$easing: linear;
$color: $subject-blue;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.page {
box-sizing: border-box;
width: min(300px, 88vw);
}
.page__label {
margin: 0 0 $sp-4;
color: $subject-ink;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.4;
font-family: inherit;
}
.track {
height: $sp-2;
border-radius: $r-pill;
background: rgba($subject-ink, .12);
overflow: hidden;
}
.track__fill {
height: 100%;
border-radius: inherit;
background: $color;
transform-origin: left center; // 규칙8: 진행바 왼쪽 채움
transform: scaleX(0);
}
// 데모: 진행률이 0→1 로 다 차고 나면 리셋해 2초마다 반복
.track__fill.is-demo {
animation: fill-loop $duration $easing infinite;
}
@keyframes fill-loop {
0% { transform: scaleX(0); }
90% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 다 찬 상태로 고정 ──────
@media (prefers-reduced-motion: reduce) {
.track__fill.is-demo { animation: none; transform: scaleX(1); }
}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);
}
.page {
box-sizing: border-box;
width: min(300px, 88vw);
}
.page__label {
margin: 0 0 16px;
color: #17141a;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.4;
font-family: inherit;
}
.track {
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.track__fill {
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0);
}
.track__fill.is-demo {
animation: fill-loop 2000ms linear infinite;
}
@keyframes fill-loop {
0% {
transform: scaleX(0);
}
90% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.track__fill.is-demo {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./ArbitraryTimelineProgress.module.scss";
export type ArbitraryTimelineProgressProps = {
label?: string;
/** 진행률이 다 차는 시간, ms — 실제 scroll-timeline 대신 안전한 루프로 재현 */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ArbitraryTimelineProgress({
label = "A progress bar that fills as you read",
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: ArbitraryTimelineProgressProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.page} style={vars}>
<p className={styles.page__label}>{label}</p>
<div className={styles.track}>
<div className={styles.track__fill} />
</div>
</div>
);
}04 · 임의값 scroll-timeline — 측정 누적면적 1.661% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
읽기 진행바에
긴 글의 읽기 진행바나 랜딩 스크롤 인디케이터에 씁니다.
<!-- 지원 브라우저에서만: 아래 구문은 caniuse.com 에서 지원 범위를 먼저 확인하세요 -->
<div class="h-2 rounded-full bg-black/10">
<div
class="h-full rounded-full bg-[#2f6df6] origin-left
animate-[fill_1s_linear] [animation-timeline:scroll()] [animation-range:0%_100%]"
></div>
</div>
05group-hover 아이콘
group 과 group-hover 의 짝
부모에 group을 걸고 아이콘에 group-hover:translate-x-1을 걸면, 카드 어디를 호버하든 안쪽 화살표만 오른쪽으로 4px 미끄러집니다.
<main class="stage">
<a class="card group is-demo" href="#" onclick="return false">
<span class="card__text">더 알아보기</span>
<span class="arrow">
<span class="arrow__line"></span>
<span class="arrow__head"></span>
</span>
</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 200ms;
$easing: ease-out;
$color: $subject-blue;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.card {
display: inline-flex;
align-items: center;
gap: $sp-3;
padding: $sp-4 $sp-6;
border-radius: $r-control;
background: rgba($subject-cream, .08);
border: 1px solid rgba($subject-cream, .18);
text-decoration: none;
cursor: pointer;
}
.card__text {
color: $subject-cream;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
}
.arrow {
position: relative;
width: 18px;
height: 14px;
}
.arrow__line {
position: absolute;
left: 0; top: 6px;
width: 14px; height: 2px;
background: $color;
transform-origin: left center; // 규칙8: 화살대 왼쪽 고정 신장
transition: transform $duration $easing;
}
.arrow__head {
position: absolute;
right: 0; top: 1px;
width: 9px; height: 9px;
border-top: 2px solid $color;
border-right: 2px solid $color;
transform: rotate(45deg);
transition: transform $duration $easing;
}
// 실사용: group-hover — 카드 어디를 호버해도 화살표만 오른쪽으로 4px
.card:hover .arrow__line,
.card:focus-visible .arrow__line { transform: translateX(4px) scaleX(1.08); }
.card:hover .arrow__head,
.card:focus-visible .arrow__head { transform: translate(4px, 0) rotate(45deg); }
// 데모: 호버를 걸 수 없으니 2초마다 같은 움직임을 스스로 반복
.card.is-demo .arrow__line { animation: nudge-line 2s $easing infinite; }
.card.is-demo .arrow__head { animation: nudge-head 2s $easing infinite; }
@keyframes nudge-line {
0%, 30% { transform: translateX(0) scaleX(1); }
55% { transform: translateX(4px) scaleX(1.08); }
80%, 100% { transform: translateX(0) scaleX(1); }
}
@keyframes nudge-head {
0%, 30% { transform: translate(0, 0) rotate(45deg); }
55% { transform: translate(4px, 0) rotate(45deg); }
80%, 100% { transform: translate(0, 0) rotate(45deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.arrow__line, .arrow__head { transition: none; }
.card.is-demo .arrow__line, .card.is-demo .arrow__head { animation: none; }
}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 {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.18);
text-decoration: none;
cursor: pointer;
}
.card__text {
color: #fff7e6;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
}
.arrow {
position: relative;
width: 18px;
height: 14px;
}
.arrow__line {
position: absolute;
left: 0;
top: 6px;
width: 14px;
height: 2px;
background: #2f6df6;
transform-origin: left center;
transition: transform 200ms ease-out;
}
.arrow__head {
position: absolute;
right: 0;
top: 1px;
width: 9px;
height: 9px;
border-top: 2px solid #2f6df6;
border-right: 2px solid #2f6df6;
transform: rotate(45deg);
transition: transform 200ms ease-out;
}
.card:hover .arrow__line,
.card:focus-visible .arrow__line {
transform: translateX(4px) scaleX(1.08);
}
.card:hover .arrow__head,
.card:focus-visible .arrow__head {
transform: translate(4px, 0) rotate(45deg);
}
.card.is-demo .arrow__line {
animation: nudge-line 2s ease-out infinite;
}
.card.is-demo .arrow__head {
animation: nudge-head 2s ease-out infinite;
}
@keyframes nudge-line {
0%, 30% {
transform: translateX(0) scaleX(1);
}
55% {
transform: translateX(4px) scaleX(1.08);
}
80%, 100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes nudge-head {
0%, 30% {
transform: translate(0, 0) rotate(45deg);
}
55% {
transform: translate(4px, 0) rotate(45deg);
}
80%, 100% {
transform: translate(0, 0) rotate(45deg);
}
}
@media (prefers-reduced-motion: reduce) {
.arrow__line, .arrow__head {
transition: none;
}
.card.is-demo .arrow__line, .card.is-demo .arrow__head {
animation: none;
}
}import type { CSSProperties, MouseEvent } from "react";
import styles from "./GroupHoverIconCard.module.scss";
export type GroupHoverIconCardProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GroupHoverIconCard({
label = "Learn more",
duration = 200,
easing = "ease-out",
color = "#2f6df6",
}: GroupHoverIconCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<a className={styles.card} href="#" style={vars} onClick={(e: MouseEvent) => e.preventDefault()}>
<span className={styles.card__text}>{label}</span>
<span className={styles.arrow}>
<span className={styles.arrow__line} />
<span className={styles.arrow__head} />
</span>
</a>
);
}05 · group-hover 아이콘 — 측정 누적면적 0.098% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
카드형 CTA 화살표에
카드형 CTA나 리스트 항목의 화살표에 씁니다.
<a class="group inline-flex items-center gap-3 rounded-xl bg-white/8 px-6 py-4" href="/more">
<span class="text-[#fff7e6] font-semibold">더 알아보기</span>
<svg class="size-4 text-[#2f6df6] transition-transform duration-200
group-hover:translate-x-1 group-focus-visible:translate-x-1" viewBox="0 0 24 24">
<path d="M4 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" />
</svg>
</a>
06data-state 기반 애니메이션(shadcn)
JS 는 data-state 만 토글한다
shadcn 컴포넌트들처럼 JS가 data-state="open"만 토글하고, CSS는 data-[state=open]: 변형으로 scale-y-100과 opacity-100을 줍니다.

<main class="stage">
<div class="acc">
<button class="acc__trigger" type="button" aria-expanded="false">배송 안내</button>
<div class="acc__panel is-demo" data-state="closed">
<p>결제 후 1~2일 안에 출고되고, 도서산간은 하루 더 걸립니다.</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 220ms;
$easing: ease-out;
$color: $subject-ink;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.acc {
box-sizing: border-box;
width: min(280px, 88vw);
}
.acc__trigger {
width: 100%;
padding: $sp-4 $sp-5;
border: 0;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.acc__panel {
margin-top: $sp-2;
padding: $sp-4 $sp-5;
border-radius: $r-control;
background: rgba($subject-ink, .06);
transform-origin: top center; // 규칙8: 패널 윗변에서 펼쳐짐
transform: scaleY(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
p { margin: 0; color: $subject-ink; font-weight: 400; font-size: clamp(13px, 3.6vw, 14px); line-height: 1.5; font-family: inherit; }
}
// 실사용: [data-state=open] 만 보고 움직인다. width/height 는 건드리지 않는다
.acc__panel[data-state="open"] {
transform: scaleY(1);
opacity: 1;
}
// 데모: transition 은 getAnimations() 에 안 잡혀 렌더 시계로 못 잰다 (blog-demo 실패시표) —
// is-demo 는 같은 모양을 @keyframes 로 2초마다 반복해 측정 가능하게 만든다
.acc__panel.is-demo {
animation: acc-loop 2s $easing infinite;
}
@keyframes acc-loop {
0%, 8% { transform: scaleY(0); opacity: 0; }
35%, 65% { transform: scaleY(1); opacity: 1; }
92%, 100% { transform: scaleY(0); opacity: 0; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.acc__panel { transition: opacity $duration linear; transform: none; animation: none; }
.acc__panel[data-state="closed"] { opacity: 0; }
}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);
}
.acc {
box-sizing: border-box;
width: min(280px, 88vw);
}
.acc__trigger {
width: 100%;
padding: 16px 20px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.acc__panel {
margin-top: 8px;
padding: 16px 20px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
transform-origin: top center;
transform: scaleY(0);
opacity: 0;
transition: transform 220ms ease-out, opacity 220ms ease-out;
}
.acc__panel p {
margin: 0;
color: #17141a;
font-weight: 400;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1.5;
font-family: inherit;
}
.acc__panel[data-state=open] {
transform: scaleY(1);
opacity: 1;
}
.acc__panel.is-demo {
animation: acc-loop 2s ease-out infinite;
}
@keyframes acc-loop {
0%, 8% {
transform: scaleY(0);
opacity: 0;
}
35%, 65% {
transform: scaleY(1);
opacity: 1;
}
92%, 100% {
transform: scaleY(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.acc__panel {
transition: opacity 220ms linear;
transform: none;
animation: none;
}
.acc__panel[data-state=closed] {
opacity: 0;
}
}// data-state 를 토글하는 최소 로직. CSS 가 [data-state=open] 을 보고 움직인다.
var t = document.querySelector('.acc__trigger');
var p = document.querySelector('.acc__panel');
function toggle() {
var open = p.getAttribute('data-state') === 'open';
p.setAttribute('data-state', open ? 'closed' : 'open');
t.setAttribute('aria-expanded', String(!open));
}
t.addEventListener('click', toggle); // 실사용: 클릭마다 data-state 토글 (데모 루프는 .is-demo 키프레임)import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DataStateAccordion.module.scss";
export type DataStateAccordionProps = {
trigger?: string;
children?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DataStateAccordion({
trigger = "Shipping info",
children = "Ships within 1-2 days of payment; remote areas take one extra day.",
duration = 220,
easing = "ease-out",
color = "#17141a",
}: DataStateAccordionProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.acc} style={vars}>
<button
type="button"
className={styles.acc__trigger}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
<div className={styles.acc__panel} data-state={open ? "open" : "closed"}>
<p>{children}</p>
</div>
</div>
);
}06 · data-state 기반 애니메이션(shadcn) — 측정 누적면적 14.289% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
레이아웃을 다시 계산하지 않기
width나 height, grid-template-rows로 레이아웃을 다시 계산하게 하지 않는 게 핵심입니다.
<button onclick="p.dataset.state = p.dataset.state === 'open' ? 'closed' : 'open'"
aria-expanded="false" class="w-full rounded-xl bg-[#17141a] px-5 py-4 text-left text-[#fff7e6]">
배송 안내
</button>
<div id="p" data-state="closed"
class="origin-top scale-y-0 opacity-0 transition-all duration-200
data-[state=open]:scale-y-100 data-[state=open]:opacity-100">
<p>결제 후 1~2일 안에 출고됩니다.</p>
</div>
07motion-safe 가드
motion-safe 와 motion-reduce 를 나란히
@media를 따로 안 써도 motion-safe:-translate-y-1과 motion-reduce:shadow-md를 나란히 걸면, 움직임을 줄인 사람에겐 그림자만 옅어지고 뜨는 동작 자체가 처음부터 안 걸립니다.

<main class="stage">
<button class="btn is-demo" type="button">변경사항 저장</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 220ms;
$easing: cubic-bezier(.2, .8, .2, 1);
$color: $subject-cream;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.btn {
border: 0;
border-radius: $r-control;
padding: $sp-4 $sp-8;
font-weight: 700;
font-size: clamp(15px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: $subject-ink;
background: $color;
cursor: pointer;
box-shadow: $shadow-press;
}
// 실사용: motion-safe: 에서만 뜨고, motion-reduce: 는 그림자만 옅게
@media (prefers-reduced-motion: no-preference) {
.btn {
transition: transform $duration $easing, box-shadow $duration $easing;
&:hover, &:focus-visible { transform: translateY(-4px); box-shadow: $shadow-raised; }
&:active { transform: translateY(-1px); }
}
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: box-shadow $duration linear; }
.btn:hover, .btn:focus-visible { box-shadow: $shadow-raised; }
}
// 데모: 호버를 강제로 2초마다 반복 — reduced-motion 이 아닐 때만
.btn.is-demo {
@media (prefers-reduced-motion: no-preference) {
animation: lift-loop 2s $easing infinite;
}
}
@keyframes lift-loop {
0%, 25% { transform: translateY(0); box-shadow: $shadow-press; }
50% { transform: translateY(-4px); box-shadow: $shadow-raised; }
75%, 100% { transform: translateY(0); 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
border: 0;
border-radius: 12px;
padding: 16px 32px;
font-weight: 700;
font-size: clamp(15px, 4vw, 17px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (prefers-reduced-motion: no-preference) {
.btn {
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:hover, .btn:focus-visible {
transform: translateY(-4px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.btn:active {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: box-shadow 220ms linear;
}
.btn:hover, .btn:focus-visible {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: no-preference) {
.btn.is-demo {
animation: lift-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
}
@keyframes lift-loop {
0%, 25% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50% {
transform: translateY(-4px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
75%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}import type { CSSProperties } from "react";
import styles from "./MotionSafeButton.module.scss";
export type MotionSafeButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MotionSafeButton({
label = "Save changes",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: MotionSafeButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.btn} style={vars}>
{label}
</button>
);
}07 · motion-safe 가드 — 측정 누적면적 3.297% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
프라이머리 CTA 호버에
저장 버튼 같은 프라이머리 CTA의 호버 피드백에 씁니다.
<button
class="rounded-xl bg-[#fff7e6] px-8 py-4 font-bold shadow-md transition-all duration-200
motion-safe:hover:-translate-y-1 motion-safe:hover:shadow-lg
motion-reduce:hover:shadow-md"
>
변경사항 저장
</button>
08spin 유틸
기본 spin 을 그대로 두고 늘리기
기본 제공 animate-spin(1초)은 그대로 두고 @theme에 --animate-spin-slow: spin 3s linear infinite;만 더하면, 로고와 로딩 링이 같은 회전을 서로 다른 속도로 함께 돕니다.

<main class="stage">
<div class="row">
<span class="ring ring--fast" aria-hidden="true"></span>
<span class="ring ring--slow" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1000ms; // 기본 animate-spin 과 같은 1초
$easing: linear;
$color: $subject-blue;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.row {
display: flex;
align-items: center;
gap: $sp-8;
}
.ring {
box-sizing: border-box;
width: 56px;
height: 56px;
border-radius: 50%;
border: 5px solid rgba($subject-ink, .14);
}
// 기본 --animate-spin 그대로 (1초 linear)
.ring--fast {
border-top-color: $color;
animation: spin $duration $easing infinite;
}
// 확장한 --animate-spin-slow (3초 linear) — 같은 spin 키프레임을 속도만 다르게 공유
.ring--slow {
border-top-color: $subject-ink;
animation: spin 3000ms $easing infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ring--fast, .ring--slow { animation: none; }
}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);
}
.row {
display: flex;
align-items: center;
gap: 32px;
}
.ring {
box-sizing: border-box;
width: 56px;
height: 56px;
border-radius: 50%;
border: 5px solid rgba(23, 20, 26, 0.14);
}
.ring--fast {
border-top-color: #2f6df6;
animation: spin 1000ms linear infinite;
}
.ring--slow {
border-top-color: #17141a;
animation: spin 3000ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring--fast, .ring--slow {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./SpinUtilRings.module.scss";
export type SpinUtilRingsProps = {
/** 기본 animate-spin 속도, ms */
duration?: number;
/** --animate-spin-slow 속도, ms */
slowDuration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--slow-duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SpinUtilRings({
duration = 1000,
slowDuration = 3000,
easing = "linear",
color = "#2f6df6",
}: SpinUtilRingsProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--slow-duration": `${slowDuration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.row} style={vars}>
<span className={styles.ring} aria-hidden="true" />
<span className={`${styles.ring} ${styles["ring--slow"]}`} aria-hidden="true" />
</div>
);
}08 · spin 유틸 — 측정 누적면적 1.432% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
키프레임을 새로 만들지 않는다
새 @keyframes를 다시 만들 필요가 없습니다.
<style>
@theme {
--animate-spin-slow: spin 3s linear infinite;
}
</style>
<div class="flex items-center gap-8">
<span class="animate-spin size-14 rounded-full border-4 border-black/10 border-t-[#2f6df6]"></span>
<span class="animate-spin-slow size-14 rounded-full border-4 border-black/10 border-t-[#17141a]"></span>
</div>
09스태거 유틸
--i 하나로 만드는 순서
리스트 항목마다 [animation-delay:calc(var(--i)*80ms)]으로 --i만 0부터 1씩 늘려 주면, 같은 animate-fade-slide 클래스 하나가 순서대로 80ms씩 늦게 떠오릅니다.

<main class="stage">
<ul class="list is-demo">
<li class="list__item" style="--i:0">새 댓글이 달렸습니다</li>
<li class="list__item" style="--i:1">팔로워가 늘었습니다</li>
<li class="list__item" style="--i:2">결제가 완료됐습니다</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 400ms;
$easing: ease-out;
$color: $subject-cream;
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(280px, 88vw);
}
.list__item {
padding: $sp-2 $sp-4;
border-radius: $r-sm;
background: rgba($subject-cream, .08);
color: $color;
font-weight: 500;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(10px);
}
// 실사용: 각 항목의 --i 만 바꿔 [animation-delay:calc(var(--i)*80ms)] 로 순서를 만든다
.list.is-demo .list__item {
animation: stagger-in-loop 2s $easing infinite;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes stagger-in-loop {
0% { opacity: 0; transform: translateY(10px); }
20% { opacity: 1; transform: translateY(0); }
75% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
// 실사용 단발 버전
.list.is-appear .list__item {
animation: stagger-in $duration $easing both;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.list__item { animation: none !important; 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: min(280px, 88vw);
}
.list__item {
padding: 8px 16px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 500;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(10px);
}
.list.is-demo .list__item {
animation: stagger-in-loop 2s ease-out infinite;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes stagger-in-loop {
0% {
opacity: 0;
transform: translateY(10px);
}
20% {
opacity: 1;
transform: translateY(0);
}
75% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
.list.is-appear .list__item {
animation: stagger-in 400ms ease-out both;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.list__item {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./StaggerUtilList.module.scss";
export type StaggerUtilListProps = {
items?: string[];
duration?: number;
/** 항목 사이 지연, ms — [animation-delay:calc(var(--i)*80ms)] 의 80 */
step?: number;
easing?: string;
color?: string;
};
type ItemVars = CSSProperties & { "--i"?: number };
type Vars = CSSProperties & {
"--duration"?: string;
"--step"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function StaggerUtilList({
items = [
"A new comment was posted",
"You have a new follower",
"Payment completed",
],
duration = 400,
step = 80,
easing = "ease-out",
color = "#fff7e6",
}: StaggerUtilListProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--step": `${step}ms`,
"--easing": easing,
"--color": color,
};
return (
<ul className={styles.list} style={vars}>
{items.map((text, i) => {
const itemVars: ItemVars = { "--i": i };
return (
<li key={text} className={styles.list__item} style={itemVars}>
{text}
</li>
);
})}
</ul>
);
}09 · 스태거 유틸 — 측정 누적면적 20.843% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
알림 리스트 순차 로드에
알림 리스트나 대시보드 카드의 순차 로드에 씁니다.
<ul class="flex flex-col gap-2">
<li style="--i:0" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
새 댓글이 달렸습니다
</li>
<li style="--i:1" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
팔로워가 늘었습니다
</li>
<li style="--i:2" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
결제가 완료됐습니다
</li>
</ul>
어디서 깨지는가 — 함정 하나
v4 는 설정 파일을 읽지 않는다
레이아웃 속성 대신 transform 으로 접는 판단은 겹치는 레이어가 늘수록 값이 커지는데, 종이를 여러 겹 오려 붙이는 종이 질감 랜딩 키트에서는 같은 선택이 섹션 전체의 부드러움을 가릅니다.
가장 자주 겪는 함정은 v3에서 v4로 넘어올 때입니다. tailwind.config.js의 theme.extend.keyframes와 animation을 그대로 두고 클래스만 animate-pop-spin으로 바꿔 쓰면 아무 일도 일어나지 않습니다. Tailwind v4는 CSS-first라 설정 파일을 아예 읽지 않고, tailwind custom keyframes 정의는 공식 문서가 안내하는 대로 CSS의 @theme 블록 안에 있어야 합니다. 06 data-state 아코디언을 만들 때도 비슷한 함정이 있었습니다. grid-template-rows: 0fr → 1fr로 높이를 접었다 펴는 방법이 흔히 쓰이지만, 이건 브라우저가 매 프레임 트랙 크기를 다시 계산하게 만드는 레이아웃 속성이라 이 편의 transform·opacity 원칙과 어긋납니다. 그래서 06 패널은 scaleY와 transform-origin: top으로 접었습니다. 08 spin 유틸에서도 비슷한 걸 겪었는데, 두 번째 링에 새 @keyframes를 또 만드는 대신 animation-duration만 오버라이드했더니 컴파일된 CSS 크기가 그대로였습니다. 같은 spin 키프레임을 속도만 다르게 공유하는 쪽이 유틸 개수를 늘리지 않는 방법이고, 지금까지 다룬 값이 그대로 들어간 아홉 개 파일 묶음은 mtsjkxrf으로 열리며, 아래 버튼에서 받을 수 있습니다.
접근성
끄는 방식이 둘로 갈린다
아홉 개 전부 prefers-reduced-motion을 처리하지만 방식이 둘로 갈립니다. 01·02·03·04·06·08·09는 motion-reduce:animate-none 계열로 애니메이션 자체를 끄고 최종 상태만 보여주고, 05·07은 motion-safe: 변형 안에만 hover 동작을 넣어 애초에 걸지 않습니다. 06의 패널은 움직임을 꺼도 열림 상태가 opacity로는 표시되도록 남겨 뒀습니다. 이 미디어쿼리의 지원 범위는 MDN prefers-reduced-motion에서 확인할 수 있습니다.
이어서 볼 글
다른 애니메이션 유틸·모음은 CSS 애니메이션 카테고리에 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
v3의 tailwind.config.js 설정을 그대로 두면 왜 tailwind keyframes not working 이 되나요?
아니요. v4는 설정 파일을 읽지 않는 CSS-first 구조라 theme.extend.keyframes와 animation은 무시됩니다. @theme 블록 안에 --animate-* 변수와 @keyframes로 옮겨야 클래스가 생성됩니다.
tw-animate-css 같은 플러그인 없이도 진입 애니메이션을 만들 수 있나요?
가능합니다. 03처럼 @theme에 커스텀 --animate-* 변수 하나만 정의하면 플러그인 없이도 같은 효과를 낼 수 있습니다. 다만 방향별 유틸(slide-in-from-*)처럼 조합이 많다면 플러그인이 코드량을 줄여 줍니다.
React에서는 data-state 토글을 어떻게 옮기나요?
zip의 react/ 폴더는 useState로 열림 여부를 들고 있다가 data-state 속성에 그대로 반영합니다. SCSS 모듈은 [data-state="open"] 선택자만 보고 움직이므로 컴포넌트 쪽 로직은 상태 하나만 바꾸면 됩니다. 상태 하나로 움직임을 지배하는 이 방식을 컴포넌트 단위로 더 넓게 풀어 둔 글이 라이브러리 없는 리액트 CSS 애니메이션입니다.









