Tailwind アニメーション9選(v4) 一行でコピペOK
tailwind アニメーションは、tailwind.config.jsを使わずCSSの@themeブロックに--animate-変数と@keyframesを一緒に書くと、その名前がそのままユーティリティクラスになるTailwind v4のCSS-firstな作り方です。
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
自動再生 · タイルを押すとその項目へ · すべて1つの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の構文を学ぶ順に揃えています。まず@themeでカスタムアニメーションを登録する方法(01〜03)、次にブラウザ対応が割れる機能を安全に再現する方法(04)、状態に反応させる書き方(05・06)、アクセシビリティのガード(07)、最後に既存ユーティリティを拡張する書き方(08・09)という流れです。コードはTailwindのクラス片だけを抜き出し、完全版とSCSS版は圧縮ファイル一つにまとめてあります。クラス構文を使わない素のCSSで同じ結果へ辿り着く道は背景アニメーションで先に通っており、文章の一行だけを目立たせたい場合は@theme構文の次の一段としてテキスト強調アニメーションがあります。
01@themeカスタムキーフレーム
@themeに変数とキーフレームを書く
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
バッジの登場アニメーションに
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
モーダルとトーストの初回表示に
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ヒーロー文の登場に
<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 スクロール アニメーション」に専用ユーティリティはない
Tailwindにはscroll-timeline専用のユーティリティがなく、任意プロパティ[animation-timeline:scroll()]で付けますが、ブラウザごとに対応が割れるためこのデモは同じ進捗を安全なループで再現しています。長文記事の読了プログレスバーやLPのスクロール進捗インジケーターに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
読了プログレスバーに
<!-- 対応ブラウザでのみ有効。下の構文を使う前に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滑ります。カード型CTAやリスト項目の矢印に向いています。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
カード型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を与えます。widthやheight、grid-template-rowsでレイアウトを再計算させないのが要点です。

<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日で出荷され、離島・山間部はさらに1日かかります。</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
レイアウトを再計算させない
<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:hover:-translate-y-1とmotion-reduce:hover:shadow-mdを並べて付けるだけで、動きを減らした人には影が薄くなるだけで浮き上がる動作自体が最初から付きません。「tailwind ボタン アニメーション」で探すような、保存ボタンのプライマリCTAのホバーフィードバックに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
プライマリ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;を足すだけで、ロゴとローディングリングが同じ回転を別々の速度で一緒に回ります。新しい@keyframesを作り直す必要がありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
キーフレームを作り直さない
<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を1つずつ増やすだけ
リスト項目ごとに[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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
通知リストの順次ロードに
<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で開閉する判断は、重なるレイヤーが増えるほど効き目が大きくなります。紙を何枚も切って貼り重ねるペーパーカットのLPキットでは、同じ判断がセクション全体の滑らかさを分けます。
一番よく踏む落とし穴はv3からv4への移行時です。tailwind.config.jsのtheme.extend.keyframesとanimationをそのまま残してクラス名だけanimate-pop-spinに変えても、何も起きません。Tailwind v4はCSS-firstで設定ファイルを読まず、公式ドキュメントが案内するとおり定義はCSSの@themeブロック内に置く必要があります。06のdata-stateアコーディオンを組むときにも似た落とし穴がありました。grid-template-rows: 0fr→1frで高さを開閉する方法はよく使われますが、これはブラウザが毎フレームでトラックサイズを再計算するレイアウトプロパティなので、この記事のtransform・opacity原則と衝突します。9項目はどれも同じ原則を守っているため、06のアコーディオンもscaleYとtransform-origin: topで開閉する形にしました(高さの空間はあらかじめ確保される代わり、実際に使うときは親をoverflow:hiddenで囲むと安全です)。08のspinユーティリティでも近いことがありました。二つ目のリングに新しい@keyframesをまた作る代わりにanimation-durationだけを上書きしたところ、コンパイル後のCSSサイズは増えませんでした。同じspinキーフレームを速度だけ変えて共有するほうが、ユーティリティの数を増やさずに済みます。ここまでの修正を反映した9ファイルの圧縮パスワードはmtsjkxrfで、下のボタンから受け取れます。
アクセシビリティ
止め方は三通りに分かれる
9項目ともprefers-reduced-motionに対応していますが、やり方は三通りに分かれます。01・02・03・04・06・08・09はmotion-reduce:animate-none系でアニメーションそのものを止めて最終状態だけを見せます。07はmotion-safe:修飾子の中にだけhover動作を書いて、そもそも動きを付けません。05はホバー時の移動そのものは残しつつtransitionだけを外し、アニメーションなしで瞬時に切り替わるようにしています。06のパネルは動きを止めても開閉状態がopacityで分かるように残しています。
<span class="animate-pop-spin motion-reduce:animate-none rounded-full bg-[#fff7e6] px-4 py-2 font-bold">NEW</span>
<button class="shadow-md transition-all duration-200 motion-safe:hover:-translate-y-1 motion-safe:hover:shadow-lg motion-reduce:hover:shadow-md">
変更を保存
</button>
対応範囲の確認先
この対応範囲はMDNのprefers-reduced-motionで確認できます。
次に読む
Tailwindを使わない素のCSSだけのアニメーションはCSSアニメーション一覧ページに集めてあります。ブログ全体の狙いはこのブログについてのページに書きました。
FAQ
v3のtailwind.config.jsにあったアニメーション設定はv4でもそのまま使えますか?
いいえ。v4は設定ファイルを読まないCSS-first構造なので、theme.extend.keyframesとtheme.extend.animationは無視されます。同じ内容をCSSの@themeブロック内へ--animate-*変数と@keyframesとして書き直す必要があります。
「tailwind css アニメーション」はプラグインなしでも登場アニメーションを作れますか?
作れます。03のように@themeへ独自の--animate-*変数を一つ定義するだけで、プラグインなしでも同じ効果が出せます。ただしslide-in-from-*のように方向違いの組み合わせが多い場合は、プラグインの方がコード量を減らせます。
Reactでdata-stateの切り替えはどう書けばいいですか?
zipのreact/フォルダではuseStateで開閉状態を持ち、それをそのままdata-state属性へ反映しています。SCSSモジュール側は[data-state="open"]セレクタだけを見て動くので、コンポーネント側は状態を一つ切り替えるだけで済みます。状態ひとつで動きを支配するこの書き方をコンポーネント単位でさらに広げたのがライブラリなしのReact CSSアニメーションです。









