9 Tailwind v4 Animations — One Line, Copy-Paste
These nine Tailwind v4 animations turn one utility class into a real keyframe, a hover state, or a reduced-motion guard, using only the framework's
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 @theme custom keyframes
- 02 tw-animate-css entrance utility
- 03 Fade + slide utility
- 04 Arbitrary-value scroll-timeline
- 05 group-hover icon
- 06 data-state animation (shadcn)
- 07 motion-safe guard
- 08 Spin utility
- 09 Stagger utility
The nine below move from registering a brand-new animation to extending one Tailwind already ships. 01 through 03 cover the CSS-first way to declare a custom keyframe inside @theme, and 04 shows how to fake a feature browsers still disagree on. 05 and 06 react to hover and to a JavaScript-driven state, 07 is the accessibility guard on its own, and 08 and 09 stretch a built-in utility instead of writing a new one. Only the Tailwind fragment sits in the code block below each entry — the compiled CSS, the plain-SCSS twin, and a React version of all nine live together in one archive. If you would rather reach the same result with no utility classes at all, the particle backgrounds walk that road in plain CSS, and when only one line of text needs the emphasis, the text highlight animations are where this @theme grammar goes next.
01@theme Custom Keyframes
Why a tailwind v4 custom animation lives in CSS
Writing an --animate-pop-spin variable together with its @keyframes inside a CSS @theme block turns that name straight into an animate-pop-spin utility class — no tailwind.config.js required.

<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 custom keyframes — Measured area 2.196% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a one-shot badge fits
It suits a badge that only needs to spin into view once, such as a new-item tag or an updated notification count.
<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 Entrance Utility
One plugin combination, one card
Installing the tw-animate-css plugin and applying its animate-in fade-in slide-in-from-top-8 duration-500 combination makes a card start 32px higher and fully transparent, then settle into place while it fades in.

<main class="stage">
<div class="card is-demo">
<p class="card__title">Deployed</p>
<p class="card__sub">The new version just went live</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 entrance utility — Measured area 27.637% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
A toast or a modal's first appearance
It fits a toast or a modal's very first appearance, or a card sliding into a list.
npm install -D tw-animate-css
<!-- add one line to 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>
03Fade + Slide Utility
Opacity and translateY in one keyframe
Defining just one --animate-fade-slide variable inside @theme — no plugin needed — bundles opacity and translateY into a single keyframe, so a paragraph rises 16px into view with one class.

<main class="stage">
<p class="lead is-demo">One line is all it takes<br>Tailwind animation</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 · Fade + slide utility — Measured area 6.125% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
A hero line settling into place
It works well for a hero line or a section title that should feel like it's settling into place.
<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>
04Arbitrary-Value scroll-timeline
An arbitrary property instead of a utility
Tailwind ships no dedicated scroll-timeline utility, so it gets attached through the arbitrary property [animation-timeline:scroll()] instead.

<main class="stage">
<div class="page">
<p class="page__label">A progress bar that fills as you read</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 · Arbitrary-value scroll-timeline — Measured area 1.661% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the demo fakes the progress
Because browser support still varies, this demo reproduces the same fill progress with a safe looping animation rather than the real scroll-driven one.
<!-- only in supporting browsers — check caniuse.com before shipping this line -->
<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 Icon
group on the parent, translate on the icon
Putting group on the parent and group-hover:translate-x-1 on the icon means hovering anywhere on the card slides just the inner arrow 4px to the right.
<main class="stage">
<a class="card group is-demo" href="#" onclick="return false">
<span class="card__text">Learn more</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 icon — Measured area 0.098% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
A card CTA or a list row arrow
It's a light nudge that suits a card-style call to action or an arrow sitting at the end of a list row.
<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">Learn more</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 Animation (shadcn)
A script that only flips one attribute
Like shadcn's own components, a small script only toggles data-state="open" on click, and CSS reacts through the data-[state=open]: variant to drive scale-y-100 and opacity-100.

<main class="stage">
<div class="acc">
<button class="acc__trigger" type="button" aria-expanded="false">Shipping info</button>
<div class="acc__panel is-demo" data-state="closed">
<p>Ships within 1-2 days of payment; remote areas take one extra day.</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 animation (shadcn) — Measured area 14.289% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Opening without a layout recalculation
Nothing here touches width, height, or grid-template-rows, so the panel opens without forcing a layout recalculation.
<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]">
Shipping details
</button>
<div id="p" data-state="closed"
class="origin-top scale-y-0 opacity-0 transition-all duration-[220ms] ease-out
data-[state=open]:scale-y-100 data-[state=open]:opacity-100">
<p>Orders ship within one to two business days after checkout.</p>
</div>
07motion-safe Guard
Two variants, no media query
Pairing motion-safe:hover:-translate-y-1 with motion-reduce:hover:shadow-md needs no separate @media block at all.

<main class="stage">
<button class="btn is-demo" type="button">Save changes</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 guard — Measured area 3.297% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
What a reduced-motion visitor sees
A reduced-motion visitor only sees the shadow soften, because the lift itself is never attached in the first place.
<button
class="rounded-xl bg-[#fff7e6] px-8 py-4 font-bold shadow-md transition-all duration-[220ms] ease-[cubic-bezier(0.2,0.8,0.2,1)]
motion-safe:hover:-translate-y-1 motion-safe:hover:shadow-lg
motion-reduce:hover:shadow-md"
>
Save changes
</button>
08Spin Utility
Extending a built-in instead of replacing it
Leaving the built-in animate-spin (one second) untouched and adding just --animate-spin-slow: spin 3s linear infinite inside @theme lets a logo and a loading ring share the exact same rotation at two different speeds.

<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 utility — Measured area 1.432% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One keyframe, two speeds
Neither utility needs its own @keyframes block, since both point back to the same spin keyframe Tailwind already ships.
<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>
09Stagger Utility
One shared class, one changing variable
Giving each list item [animation-delay:calc(var(--i)*80ms)] and simply changing --i to 0, 1, 2 and so on makes one shared animate-fade-slide class rise in, each item landing 80ms after the last.

<main class="stage">
<ul class="list is-demo">
<li class="list__item" style="--i:0">A new comment was posted</li>
<li class="list__item" style="--i:1">You have a new follower</li>
<li class="list__item" style="--i:2">Payment completed</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 · Stagger utility — Measured area 20.843% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
A sequence instead of an instant
It reads as a sequence instead of everything appearing on screen at once.
<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">
New comment on your post
</li>
<li style="--i:1" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
You have a new follower
</li>
<li style="--i:2" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
Payment completed
</li>
</ul>
Where Do Tailwind v4 Animations Break?
v4 never reads the config file
Folding with a transform instead of a layout property matters more as layers pile up — in the paper cutout landing kit, where sections sit several sheets deep, that single choice decides whether the page still scrolls smoothly.
The most common tailwind v4 animation not working report comes from the way up from v3. Leaving theme.extend.keyframes and theme.extend.animation inside tailwind.config.js and just swapping a class name to animate-pop-spin does nothing at all, because v4 is CSS-first and never reads that file. The official theme documentation is explicit that the definition has to live inside a CSS @theme block instead. Item 06's accordion hides a related trap: the usual tailwind animate height route, moving grid-template-rows from 0fr to 1fr, looks like the obvious way to open a panel, but it forces the browser to recompute the track size on every frame. That fights the transform-and-opacity approach the rest of this set relies on, so this article swaps in scaleY with transform-origin: top to sidestep the recalculation. The trade-off is that the panel's layout space still stays reserved either way. Item 08's second ring carries a smaller version of the same lesson: writing it a second @keyframes block would only grow the compiled CSS. Overriding just animation-duration on the shared spin keyframe is all a second speed needs. Every one of these nine, wired up with its SCSS twin and a React version, sits in the download and opens with mtsjkxrf typed exactly as it appears on this line, with no spaces added anywhere.
Accessibility
Two ways to answer reduced motion
All nine handle prefers-reduced-motion, but not with the same technique. Items 01 through 04, 06, 08, and 09 pair their custom keyframe with motion-reduce:animate-none, so the animation itself turns off. Only the finished state stays on screen — a settled badge, a visible card, a filled track. Items 05 and 07 never attach the motion in the first place, since motion-safe: scopes the hover lift and the group-hover nudge to visitors who haven't asked for less movement. Item 06's panel keeps opacity-100 doing the work of showing "open" even with motion turned off, so the state itself is never lost, only the transition getting there. The same guard reads just as plainly written as a raw media query:
@media (prefers-reduced-motion: reduce) {
.badge { animation: none; }
}
The same guard as a raw media query
MDN's prefers-reduced-motion reference lists the operating-system toggles this media feature reads from.
Where the rest lives
The rest of this site's CSS animation posts sit in the CSS category hub, and what's actually inside the download versus what's free to read here is covered on the about page.
FAQ
Do these tailwind animation examples work without the tw-animate-css plugin?
Eight of the nine need no plugin at all — @theme alone covers custom keyframes, state-based variants, and the built-in animate-spin extension. Only item 02's directional entrance classes come from tw-animate-css, and item 03 shows how to hand-roll that same kind of entrance with a single @theme variable instead.
Can I mix these Tailwind v4 animations with plain CSS or SCSS?
Yes — every class here compiles down to ordinary CSS, so nothing stops a hand-written @keyframes block from sitting next to a Tailwind utility in the same stylesheet. The archive ships an SCSS file per item that reproduces each animation without Tailwind at all, so the two approaches sit side by side for comparison.
Why does item 04 avoid the real animation-timeline: scroll() property?
Because browser support is still uneven — caniuse's scroll-timeline table shows Chrome and Edge shipping it at version 115, Safari following at 26 in September 2025, and Firefox still behind a flag as of September 2026. Shipping the arbitrary-property version today with no fallback would leave part of the audience looking at a track that never fills, so this demo swaps in a safe looping animation that reaches the same finished state. Driving motion from one piece of state is taken further, component by component, in CSS animations in React without a library.









