【コピペOK】CSSヒーローセクション アニメーション9選
ヒーローセクションとは、サイトを開いた瞬間に画面いっぱいに映る最初の一枚、大きな見出しとボタンが乗ったあの一画面のことです。この記事では、そこに動きを添える「ヒーローセクション アニメーション」を集めました。CSSだけ・JS不要で、そのまま貼り付けて動く9つです。
01 hero-section-animation-headline-stagger-in
02 hero-section-animation-gradient-bg-drift
03 hero-section-animation-cta-pulse-glow
04 hero-section-animation-mockup-float
05 hero-section-animation-badge-slide-in
06 hero-section-animation-marquee-strip
07 hero-section-animation-scroll-fade-behind
08 hero-section-animation-split-text-mask
09 hero-section-animation-starting-style-load
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 見出しが順番に登場
- 02 背景グラデーションの漂い
- 03 CTAパルスグロー
- 04 モックアップフロート
- 05 バッジスライドイン
- 06 マーキーストリップ
- 07 スクロールで文字が背後にフェード
- 08 分割テキストマスクリビール
- 09 @starting-styleでロード時演出
9つは人気順ではなく、訪問者が画面を開いた瞬間から出会う順番に並べています。開いてすぐ目が行く見出し(01)から始まり、背景の静かな動き(02)・視線が止まるCTAボタン(03)を経て、プロダクトを見せるモックアップ(04)と目を引くバッジ(05)で信頼を積み重ねます。その下へスクロールすると流れていく連携先ストリップ(06)までが読み込み直後の順番で、残り3つ(07〜09)はユーザーが実際にスクロールしたり何かを保存したりしたときに反応します。最初の画面を何枚も回すつもりならCSSカルーセルがその枠を作り、下へ続く物語の順番を見せたいならタイムラインのアニメーションが次の枠を埋めます。
01見出しが順番に登場
ヒーローセクション デザイン のどこで使うか
ヒーローの見出しが3行同時にではなく、1行ずつ少し遅れて登場します。

<main class="stage is-demo">
<h1 class="hero">
<span class="hero__line hero__line--1">Ship your idea.</span>
<span class="hero__line hero__line--2">Faster than ever.</span>
<span class="hero__line hero__line--3">Start <em>free</em> today.</span>
</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 3줄이 순차 등장하는 한 바퀴
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.hero {
margin: 0;
box-sizing: border-box;
max-width: clamp(220px, 78vw, 360px);
text-align: center;
display: grid;
gap: $sp-2;
}
.hero__line {
display: block;
font-weight: 800;
font-size: clamp(18px, 6vw, 28px);
line-height: 1.28;
font-family: inherit;
color: $color;
letter-spacing: -0.01em;
opacity: 0;
transform: translateY($sp-6);
}
.hero__line--3 em { font-style: normal; color: #beab7c; }
.stage.is-demo .hero__line--1 { animation: line-in-1 $duration $easing infinite; }
.stage.is-demo .hero__line--2 { animation: line-in-2 $duration $easing infinite; }
.stage.is-demo .hero__line--3 { animation: line-in-3 $duration $easing infinite; }
@keyframes line-in-1 {
0% { opacity: 0; transform: translateY($sp-6); }
10% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY($sp-6); }
}
@keyframes line-in-2 {
0%, 10% { opacity: 0; transform: translateY($sp-6); }
20% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY($sp-6); }
}
@keyframes line-in-3 {
0%, 20% { opacity: 0; transform: translateY($sp-6); }
30% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY($sp-6); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero__line--1,
.stage.is-demo .hero__line--2,
.stage.is-demo .hero__line--3 {
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);
}
.hero {
margin: 0;
box-sizing: border-box;
max-width: clamp(220px, 78vw, 360px);
text-align: center;
display: grid;
gap: 8px;
}
.hero__line {
display: block;
font-weight: 800;
font-size: clamp(18px, 6vw, 28px);
line-height: 1.28;
font-family: inherit;
color: #fff7e6;
letter-spacing: -0.01em;
opacity: 0;
transform: translateY(24px);
}
.hero__line--3 em {
font-style: normal;
color: #beab7c;
}
.stage.is-demo .hero__line--1 {
animation: line-in-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .hero__line--2 {
animation: line-in-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .hero__line--3 {
animation: line-in-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes line-in-1 {
0% {
opacity: 0;
transform: translateY(24px);
}
10% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(24px);
}
}
@keyframes line-in-2 {
0%, 10% {
opacity: 0;
transform: translateY(24px);
}
20% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(24px);
}
}
@keyframes line-in-3 {
0%, 20% {
opacity: 0;
transform: translateY(24px);
}
30% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(24px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero__line--1,
.stage.is-demo .hero__line--2,
.stage.is-demo .hero__line--3 {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./HeadlineStaggerIn.module.scss";
export type HeadlineStaggerInProps = {
lines?: [string, string, string];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HeadlineStaggerIn({
lines = ["Ship your idea.", "Faster than ever.", "Start free today."],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: HeadlineStaggerInProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<h1 className={styles.hero} style={vars}>
<span className={`${styles.line} ${styles.line1}`}>{lines[0]}</span>
<span className={`${styles.line} ${styles.line2}`}>{lines[1]}</span>
<span className={`${styles.line} ${styles.line3}`}>{lines[2]}</span>
</h1>
);
}01 · 見出しが順番に登場 — 計測 累積面積 13.299% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
行ごとに別々の@keyframesを用意し、10%・20%・30%の地点から動き出すタイミングをずらすことで、opacityとtranslateYの2つだけで順番の登場を作っています。
.hero__line {
opacity: 0;
transform: translateY(24px);
}
.hero__line--1 {
animation: line-in-1 2s $ease-spring infinite;
}
@keyframes line-in-1 {
0% { opacity: 0; transform: translateY(24px); }
10% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(24px); }
}
02背景グラデーションの漂い
何が動くか
カードよりもずっと大きなconic-gradient層を::beforeに敷き、overflow: hiddenで切り取った状態でtranslateとrotateだけをゆっくり動かします。

<main class="stage is-demo">
<section class="hero">
<p class="hero__eyebrow">New arrival</p>
<h1 class="hero__title">Build in color.</h1>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 그라디언트 한 바퀴 드리프트
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.hero {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
aspect-ratio: 4 / 3;
border-radius: $r-card;
background: $color;
padding: $sp-6;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: $sp-2;
}
// 카드보다 훨씬 큰(180% 오버사이즈) conic-gradient 층 — overflow:hidden 이 가둔다
.hero::before {
content: '';
position: absolute;
inset: -60% -60%;
background: conic-gradient(from 0deg, #d5a9b6, #a994ca, #a3c6d1, #d5a9b6);
opacity: .55;
pointer-events: none;
}
.hero__eyebrow, .hero__title { position: relative; z-index: 1; margin: 0; color: $subject-cream; }
.hero__eyebrow { font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit; letter-spacing: .08em; text-transform: uppercase; }
.hero__title { font-weight: 800; font-size: clamp(22px, 6vw, 30px); line-height: 1.2; font-family: inherit; }
// translate·rotate 만 — background-position 은 쓰지 않는다
.stage.is-demo .hero::before { animation: drift $duration $easing infinite; }
@keyframes drift {
0% { transform: translate(-6%, -4%) rotate(0deg); }
50% { transform: translate(6%, 5%) rotate(180deg); }
100% { transform: translate(-6%, -4%) rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero::before { animation: none; 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);
}
.hero {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
aspect-ratio: 4/3;
border-radius: 16px;
background: #17141a;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
}
.hero::before {
content: "";
position: absolute;
inset: -60% -60%;
background: conic-gradient(from 0deg, #d5a9b6, #a994ca, #a3c6d1, #d5a9b6);
opacity: 0.55;
pointer-events: none;
}
.hero__eyebrow, .hero__title {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
}
.hero__eyebrow {
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero__title {
font-weight: 800;
font-size: clamp(22px, 6vw, 30px);
line-height: 1.2;
font-family: inherit;
}
.stage.is-demo .hero::before {
animation: drift 2s ease-out infinite;
}
@keyframes drift {
0% {
transform: translate(-6%, -4%) rotate(0deg);
}
50% {
transform: translate(6%, 5%) rotate(180deg);
}
100% {
transform: translate(-6%, -4%) rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero::before {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./GradientBgDrift.module.scss";
export type GradientBgDriftProps = {
eyebrow?: string;
title?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GradientBgDrift({
eyebrow = "New arrival",
title = "Build in color.",
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: GradientBgDriftProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.hero} style={vars}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h1 className={styles.title}>{title}</h1>
</section>
);
}02 · 背景グラデーションの漂い — 計測 累積面積 23.185% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
background-positionを動かす方法と違って、カードのサイズが変わってもグラデーションの計算が崩れません。
.hero::before {
content: '';
position: absolute;
inset: -60% -60%;
background: conic-gradient(from 0deg, #ff477e, #7b2ff7, #4cc9f0, #ff477e);
animation: drift 2s $ease-out infinite;
}
@keyframes drift {
0% { transform: translate(-6%, -4%) rotate(0deg); }
50% { transform: translate(6%, 5%) rotate(180deg); }
100% { transform: translate(-6%, -4%) rotate(360deg); }
}
03CTAパルスグロー
どこで使うか
ボタンがscaleとbox-shadowを一緒に行き来させながら、呼吸するようにパルスします。

<main class="stage is-demo">
<button type="button" class="cta">Get started</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 펄스 한 바퀴
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.cta {
box-sizing: border-box;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-pill;
background: $color;
color: $subject-orange-deep;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
box-shadow: 0 0 0 0 rgba(255, 224, 102, 0);
transition: transform $dur-quick $ease-out;
}
.cta:hover { transform: translateY(-2px); }
.cta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// box-shadow 를 애니메이션 대상으로 쓴다 — blog-demo 규칙 1(transform·opacity 만)의 의도적 예외.
// 글로우는 box-shadow(또는 filter) 없이는 성립하지 않는다 (사실.md 기록)
.stage.is-demo .cta { animation: pulse $duration $easing infinite; }
@keyframes pulse {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 224, 102, .55); }
50% { transform: scale(1.045); box-shadow: 0 0 $sp-8 $sp-2 rgba(255, 224, 102, .55); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 224, 102, 0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .cta { animation: none; box-shadow: 0 0 $sp-6 $sp-1 rgba(255, 224, 102, .35); }
}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);
}
.cta {
box-sizing: border-box;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 999px;
background: #fff7e6;
color: #c93000;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
box-shadow: 0 0 0 0 rgba(255, 224, 102, 0);
transition: transform 220ms ease-out;
}
.cta:hover {
transform: translateY(-2px);
}
.cta:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.stage.is-demo .cta {
animation: pulse 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 224, 102, 0.55);
}
50% {
transform: scale(1.045);
box-shadow: 0 0 32px 8px rgba(255, 224, 102, 0.55);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 224, 102, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .cta {
animation: none;
box-shadow: 0 0 24px 4px rgba(255, 224, 102, 0.35);
}
}import type { CSSProperties } from "react";
import styles from "./CtaPulseGlow.module.scss";
export type CtaPulseGlowProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CtaPulseGlow({
label = "Get started",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: CtaPulseGlowProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.cta} style={vars}>
{label}
</button>
);
}03 · CTAパルスグロー — 計測 累積面積 8.864% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
ループ用のアニメーションとは別に:hoverと:focus-visibleにも実際に反応が入っているので、ループを止めてもボタン自体はちゃんと生きています。
.cta {
animation: pulse 2s $ease-pop infinite;
}
.cta:hover {
transform: translateY(-2px);
}
@keyframes pulse {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 224, 102, .55); }
50% { transform: scale(1.045); box-shadow: 0 0 32px 8px rgba(255, 224, 102, .55); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 224, 102, 0); }
}
04モックアップフロート
見えるもの
ブラウザの上部バーが付いたモックアップカード全体が、translateYとごくわずかなrotateを行き来しながらふわふわ浮いているように見えます。

<main class="stage is-demo">
<div class="mockup">
<div class="mockup__bar" aria-hidden="true">
<span class="mockup__dot"></span>
<span class="mockup__dot"></span>
<span class="mockup__dot"></span>
</div>
<div class="mockup__body" aria-hidden="true">
<span class="mockup__block mockup__block--lg"></span>
<span class="mockup__block mockup__block--sm"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 떠오름 한 바퀴
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.mockup {
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
aspect-ratio: 4 / 3;
border-radius: $r-card;
background: $subject-cream;
border: 1px solid rgba($subject-ink, .12);
box-shadow: $shadow-raised;
overflow: hidden;
}
.mockup__bar {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-4;
background: rgba($subject-ink, .06);
}
.mockup__dot { width: $sp-2; height: $sp-2; border-radius: 50%; background: rgba($subject-ink, .18); }
.mockup__dot:first-child { background: $color; }
.mockup__body { display: grid; gap: $sp-3; padding: $sp-6; }
.mockup__block { display: block; height: $sp-4; border-radius: $r-xs; background: rgba($subject-ink, .1); }
.mockup__block--lg { width: 72%; }
.mockup__block--sm { width: 46%; }
// transform 만 — translateY 와 미세 rotate 로 떠 있는 느낌
.stage.is-demo .mockup { animation: float $duration $easing infinite; }
@keyframes float {
0% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-$sp-4) rotate(-1.2deg); }
100% { transform: translateY(0) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mockup { animation: none; transform: 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);
}
.mockup {
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
aspect-ratio: 4/3;
border-radius: 16px;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mockup__bar {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: rgba(23, 20, 26, 0.06);
}
.mockup__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
}
.mockup__dot:first-child {
background: #2f6df6;
}
.mockup__body {
display: grid;
gap: 12px;
padding: 24px;
}
.mockup__block {
display: block;
height: 16px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.1);
}
.mockup__block--lg {
width: 72%;
}
.mockup__block--sm {
width: 46%;
}
.stage.is-demo .mockup {
animation: float 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes float {
0% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-16px) rotate(-1.2deg);
}
100% {
transform: translateY(0) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mockup {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./MockupFloat.module.scss";
export type MockupFloatProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MockupFloat({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MockupFloatProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.mockup} style={vars}>
<div className={styles.bar} aria-hidden="true">
<span className={styles.dot} />
<span className={styles.dotMuted} />
<span className={styles.dotMuted} />
</div>
<div className={styles.body} aria-hidden="true">
<span className={`${styles.block} ${styles.blockLg}`} />
<span className={`${styles.block} ${styles.blockSm}`} />
</div>
</div>
);
}04 · モックアップフロート — 計測 累積面積 16.142% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
どちらもtransformだけを動かしているので、レイアウトの再計算は発生しません。
.mockup {
animation: float 2s $ease-spring infinite;
}
@keyframes float {
0% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-16px) rotate(-1.2deg); }
100% { transform: translateY(0) rotate(0deg); }
}
05バッジスライドイン
どんな場面か
NEWバッジが横から滑り込んできて、しばらく留まってからまた滑り出ていく動きを繰り返します。

<main class="stage is-demo">
<div class="panel">
<span class="panel__bar panel__bar--lg" aria-hidden="true"></span>
<span class="panel__bar panel__bar--sm" aria-hidden="true"></span>
<span class="badge">NEW</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 슬라이드 인·홀드·아웃 한 바퀴
$easing: $ease-pop;
$color: #62b29d;
@include stage($stage-ink);
.panel {
box-sizing: border-box;
position: relative;
width: clamp(220px, 62vmin, 300px);
padding: $sp-6;
display: grid;
gap: $sp-3;
}
.panel__bar { display: block; height: $sp-3; border-radius: $r-xs; background: rgba($subject-cream, .16); }
.panel__bar--lg { width: 78%; }
.panel__bar--sm { width: 52%; }
.badge {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $stage-ink;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
opacity: 0;
transform: translateX($sp-10);
}
// 정지 구간(30%~65%)을 넣어 논스톱 슬라이드가 아니라 들어왔다 머물다 나가는 것처럼 보이게 한다
.stage.is-demo .badge { animation: slide-in $duration $easing infinite; }
@keyframes slide-in {
0% { opacity: 0; transform: translateX($sp-10); }
30% { opacity: 1; transform: translateX(0); }
65% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX($sp-10); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge { 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);
}
.panel {
box-sizing: border-box;
position: relative;
width: clamp(220px, 62vmin, 300px);
padding: 24px;
display: grid;
gap: 12px;
}
.panel__bar {
display: block;
height: 12px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.16);
}
.panel__bar--lg {
width: 78%;
}
.panel__bar--sm {
width: 52%;
}
.badge {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 999px;
background: #62b29d;
color: #17141a;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
opacity: 0;
transform: translateX(40px);
}
.stage.is-demo .badge {
animation: slide-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes slide-in {
0% {
opacity: 0;
transform: translateX(40px);
}
30% {
opacity: 1;
transform: translateX(0);
}
65% {
opacity: 1;
transform: translateX(0);
}
100% {
opacity: 0;
transform: translateX(40px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeSlideIn.module.scss";
export type BadgeSlideInProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BadgeSlideIn({
label = "NEW",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#06d6a0",
}: BadgeSlideInProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.panel} style={vars}>
<span className={`${styles.bar} ${styles.barLg}`} aria-hidden="true" />
<span className={`${styles.bar} ${styles.barSm}`} aria-hidden="true" />
<span className={styles.badge}>{label}</span>
</div>
);
}05 · バッジスライドイン — 計測 累積面積 1.14% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
30%〜65%の区間をあえて停止させているので、ノンストップで往復するのではなく、読める時間を確保してから消えます。
.badge {
animation: slide-in 2s $ease-pop infinite;
}
@keyframes slide-in {
0% { opacity: 0; transform: translateX(40px); }
30% { opacity: 1; transform: translateX(0); }
65% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(40px); }
}
06マーキーストリップ
何が起きるか
ヒーロー下の連携先ピルを2セットつなげたトラックが、translateX(-50%)で途切れなく流れます。

<main class="stage">
<div class="marquee">
<p class="marquee__label">Integrates with</p>
<div class="marquee__row">
<div class="marquee__track">
<span class="marquee__pill">STRIPE</span>
<span class="marquee__pill">NOTION</span>
<span class="marquee__pill">FIGMA</span>
<span class="marquee__pill">SLACK</span>
<span class="marquee__pill">LINEAR</span>
<span class="marquee__pill" aria-hidden="true">STRIPE</span>
<span class="marquee__pill" aria-hidden="true">NOTION</span>
<span class="marquee__pill" aria-hidden="true">FIGMA</span>
<span class="marquee__pill" aria-hidden="true">SLACK</span>
<span class="marquee__pill" aria-hidden="true">LINEAR</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 트랙 한 바퀴(-50%)
$easing: linear;
$color: $stage-ink;
@include stage($stage-yellow);
.marquee { width: min(340px, 88vw); display: grid; gap: $sp-2; justify-items: center; }
.marquee__label {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: .08em;
color: rgba($color, .75);
text-transform: uppercase;
}
.marquee__row { width: 100%; overflow: hidden; }
.marquee__track {
display: flex;
width: max-content;
animation: pill-scroll $duration $easing infinite;
}
.marquee__pill {
margin-right: $sp-4; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다 (BD15)
flex-shrink: 0;
display: grid;
place-items: center;
height: $sp-8;
padding: 0 $sp-4;
border-radius: $r-pill;
background: $stage-paper;
color: $color;
font-weight: 800;
font-size: 13px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
box-shadow: $shadow-press;
}
@keyframes pill-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(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);
}
.marquee {
width: min(340px, 88vw);
display: grid;
gap: 8px;
justify-items: center;
}
.marquee__label {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
color: rgba(23, 20, 26, 0.75);
text-transform: uppercase;
}
.marquee__row {
width: 100%;
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: pill-scroll 2s linear infinite;
}
.marquee__pill {
margin-right: 16px;
flex-shrink: 0;
display: grid;
place-items: center;
height: 32px;
padding: 0 16px;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@keyframes pill-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./MarqueeStrip.module.scss";
export type MarqueeStripProps = {
label?: string;
pills?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MarqueeStrip({
label = "Integrates with",
pills = ["STRIPE", "NOTION", "FIGMA", "SLACK", "LINEAR"],
duration = 2000,
easing = "linear",
color = "#17141a",
}: MarqueeStripProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars}>
<p className={styles.label}>{label}</p>
<div className={styles.row}>
<div className={styles.track}>
{pills.map((p) => (
<span key={p} className={styles.pill}>{p}</span>
))}
{pills.map((p) => (
<span key={"dup-" + p} className={styles.pill} aria-hidden="true">{p}</span>
))}
</div>
</div>
</div>
);
}06 · マーキーストリップ — 計測 累積面積 7.556% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
間隔はflexのgapではなくピルごとのmargin-rightで取っていて、gapを使うとセットの継ぎ目だけ間隔が半分になり、一周するたびに位置がずれてしまいます。
.marquee__track {
display: flex;
width: max-content;
animation: pill-scroll 2s linear infinite;
}
.marquee__pill {
margin-right: $sp-4; // gapではなくmargin-right
flex-shrink: 0;
}
@keyframes pill-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
07スクロールで文字が背後にフェード
画面で起きること
本番のサイトならscroll-timelineがヒーロー見出しの大きさ・不透明度・位置をスクロールの進み具合に直接結びつけます。

<main class="stage is-demo">
<div class="hero">
<h1 class="hero__title">Scroll to reveal.</h1>
<p class="hero__sub">The old headline recedes behind the next section.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 물러남 한 바퀴
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
// 실제 서비스라면 scroll-timeline / animation-timeline: view() 로 스크롤 진행률에 직접 묶지만,
// 브라우저 지원 차이가 커서(blog-demo 규칙 6) 데모는 같은 모양을 keyframes 루프로 대신한다 (사실.md 명시)
.hero {
box-sizing: border-box;
max-width: clamp(220px, 78vw, 360px);
text-align: center;
display: grid;
gap: $sp-3;
}
.hero__title { margin: 0; color: $color; font-weight: 800; font-size: clamp(20px, 6vw, 28px); line-height: 1.25; font-family: inherit; }
.hero__sub { margin: 0; color: $subject-ink; font-weight: 500; font-size: clamp(12px, 3.4vw, 13px); line-height: 1.5; font-family: inherit; }
.stage.is-demo .hero { animation: recede $duration $easing infinite; transform-origin: center top; }
@keyframes recede {
0% { opacity: 1; transform: scale(1) translateY(0); }
55% { opacity: .15; transform: scale(.85) translateY(-$sp-10); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero { 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);
}
.hero {
box-sizing: border-box;
max-width: clamp(220px, 78vw, 360px);
text-align: center;
display: grid;
gap: 12px;
}
.hero__title {
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: clamp(20px, 6vw, 28px);
line-height: 1.25;
font-family: inherit;
}
.hero__sub {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.5;
font-family: inherit;
}
.stage.is-demo .hero {
animation: recede 2s ease-out infinite;
transform-origin: center top;
}
@keyframes recede {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
55% {
opacity: 0.15;
transform: scale(0.85) translateY(-40px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .hero {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./ScrollFadeBehind.module.scss";
export type ScrollFadeBehindProps = {
title?: string;
subtitle?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ScrollFadeBehind({
title = "Scroll to reveal.",
subtitle = "The old headline recedes behind the next section.",
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: ScrollFadeBehindProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 서비스라면 scroll-timeline / animation-timeline: view() 로 스크롤 진행률에 직접 묶는다.
// 브라우저 지원 차이가 커서 이 컴포넌트는 같은 모양을 keyframes 루프로 대신 보여준다.
return (
<div className={styles.hero} style={vars}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.sub}>{subtitle}</p>
</div>
);
}07 · スクロールで文字が背後にフェード — 計測 累積面積 13.056% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
ただブラウザごとの対応差がまだ大きいため、このデモでは同じ見た目を@keyframesの繰り返しループで代わりに再現しています。
.hero {
transform-origin: center top;
animation: recede 2s $ease-out infinite;
}
@keyframes recede {
0% { opacity: 1; transform: scale(1) translateY(0); }
55% { opacity: .15; transform: scale(.85) translateY(-40px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
08分割テキストマスクリビール
見え方
見出しがclip-path: inset()の右側の値を100%から0%へ狭めることで、左から右へ払われるように現れます。

<main class="stage is-demo">
<div class="headline">
<h1 class="headline__mask">Type less. Ship more.</h1>
<span class="headline__underline" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 리빌·홀드·리셋 한 바퀴
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.headline {
margin: 0;
box-sizing: border-box;
max-width: clamp(220px, 80vw, 380px);
text-align: center;
}
// clip-path 를 애니메이션 대상으로 쓴다 — blog-demo 규칙 1(transform·opacity 만)의 의도적 예외.
// 마스크 리빌(왼쪽→오른쪽으로 쓸려 나오는 등장)은 clip-path 없이는 표현이 안 된다 (사실.md 기록)
.headline__mask {
display: inline-block;
margin: 0;
color: $color;
font-weight: 800;
font-size: clamp(20px, 6.4vw, 30px);
line-height: 1.3;
font-family: inherit;
clip-path: inset(0 100% 0 0);
}
.headline__underline {
display: block;
margin: $sp-2 auto 0;
width: 64px;
height: 3px;
background: #c78da8;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 언더라인 left 그어짐
}
.stage.is-demo .headline__mask { animation: reveal $duration $easing infinite; }
.stage.is-demo .headline__underline { animation: underline-grow $duration $easing infinite; }
@keyframes reveal {
0% { clip-path: inset(0 100% 0 0); }
40% { clip-path: inset(0 0% 0 0); }
85% { clip-path: inset(0 0% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes underline-grow {
0% { transform: scaleX(0); }
40% { transform: scaleX(1); }
85% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .headline__mask { animation: none; clip-path: inset(0 0% 0 0); }
.stage.is-demo .headline__underline { 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);
}
.headline {
margin: 0;
box-sizing: border-box;
max-width: clamp(220px, 80vw, 380px);
text-align: center;
}
.headline__mask {
display: inline-block;
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(20px, 6.4vw, 30px);
line-height: 1.3;
font-family: inherit;
clip-path: inset(0 100% 0 0);
}
.headline__underline {
display: block;
margin: 8px auto 0;
width: 64px;
height: 3px;
background: #c78da8;
transform: scaleX(0);
transform-origin: left center;
}
.stage.is-demo .headline__mask {
animation: reveal 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .headline__underline {
animation: underline-grow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes reveal {
0% {
clip-path: inset(0 100% 0 0);
}
40% {
clip-path: inset(0 0% 0 0);
}
85% {
clip-path: inset(0 0% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes underline-grow {
0% {
transform: scaleX(0);
}
40% {
transform: scaleX(1);
}
85% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .headline__mask {
animation: none;
clip-path: inset(0 0% 0 0);
}
.stage.is-demo .headline__underline {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./SplitTextMaskReveal.module.scss";
export type SplitTextMaskRevealProps = {
text?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SplitTextMaskReveal({
text = "Type less. Ship more.",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: SplitTextMaskRevealProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.headline} style={vars}>
<h1 className={styles.mask}>{text}</h1>
<span className={styles.underline} aria-hidden="true" />
</div>
);
}08 · 分割テキストマスクリビール — 計測 累積面積 2.719% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
同じタイミングで下の下線もscaleXで伸びていくので、テキストと下線が息を合わせて登場します。
.headline__mask {
clip-path: inset(0 100% 0 0);
animation: reveal 2s $ease-spring infinite;
}
.headline__underline {
transform: scaleX(0);
animation: underline-grow 2s $ease-spring infinite;
}
@keyframes reveal {
0% { clip-path: inset(0 100% 0 0); }
40% { clip-path: inset(0 0% 0 0); }
85% { clip-path: inset(0 0% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
09@starting-styleでロード時演出
いつ使うか
通知トーストがdisplay: noneから画面に現れるその瞬間の遷移そのものを、@starting-styleとtransition-behavior: allow-discreteだけでキーフレームなしに作っています。

<main class="stage">
<div class="scene">
<p class="scene__label">Auto-save</p>
<div class="toast" id="toast">Saved</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 220ms — @starting-style 진입 전환 길이 (반복 재생은 JS 가 담당, index.html 참고)
$easing: $ease-pop;
$color: #9fbed0;
@include stage($stage-ink);
.scene {
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
min-height: 160px;
display: grid;
place-items: center;
gap: $sp-4;
}
.scene__label {
margin: 0;
color: rgba($subject-cream, .75);
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: .08em;
text-transform: uppercase;
}
.toast {
padding: $sp-3 $sp-6;
border-radius: $r-pill;
background: $color;
color: $stage-ink;
font-weight: 800;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1;
font-family: inherit;
box-shadow: $shadow-raised;
}
// Chrome 117+ 만 지원 — 미지원 브라우저는 이 블록 밖의 기본 상태(항상 보임, 전환 없음)로 조용히 대체된다
@supports (transition-behavior: allow-discrete) {
.toast {
transition: display $duration allow-discrete, opacity $duration $easing, transform $duration $easing;
}
.toast.is-hidden {
display: none;
opacity: 0;
transform: translateY($sp-3) scale(.92);
}
// display:none → 보임으로 "새로 렌더" 되는 그 순간의 시작 상태. 키프레임 없이 CSS 만으로 진입 전환을 만든다
@starting-style {
.toast { opacity: 0; transform: translateY($sp-3) scale(.92); }
}
}
@media (prefers-reduced-motion: reduce) {
.toast { transition: none !important; }
}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);
}
.scene {
box-sizing: border-box;
width: clamp(220px, 62vmin, 300px);
min-height: 160px;
display: grid;
place-items: center;
gap: 16px;
}
.scene__label {
margin: 0;
color: rgba(255, 247, 230, 0.75);
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.toast {
padding: 12px 24px;
border-radius: 999px;
background: #9fbed0;
color: #17141a;
font-weight: 800;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
@supports (transition-behavior: allow-discrete) {
.toast {
transition: display 220ms allow-discrete, opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.toast.is-hidden {
display: none;
opacity: 0;
transform: translateY(12px) scale(0.92);
}
@starting-style {
.toast {
opacity: 0;
transform: translateY(12px) scale(0.92);
}
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none !important;
}
}// @starting-style 는 요소가 "새로 렌더" 될 때만 발동한다. 데모에서 반복 재현하려면
// display:none 으로 뺐다가(is-hidden) 두 번의 requestAnimationFrame 뒤에 다시 넣어
// 매번 새로 렌더된 것처럼 만든다 — 8줄.
(function () {
var t = document.getElementById('toast');
function loop() {
t.classList.add('is-hidden');
requestAnimationFrame(function () { requestAnimationFrame(function () { t.classList.remove('is-hidden'); }); });
}
setTimeout(function () { loop(); setInterval(loop, 350); }, 300);
})();import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StartingStyleLoad.module.scss";
export type StartingStyleLoadProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StartingStyleLoad({
label = "Saved",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#38b6ff",
}: StartingStyleLoadProps) {
const [hidden, setHidden] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// @starting-style 는 요소가 "새로 렌더" 될 때만 발동한다 — 실서비스라면 실제 저장 이벤트가
// 이 토글을 부르고, 여기서는 데모용으로 짧은 간격을 두고 반복 재현한다
useEffect(() => {
const id = setInterval(() => {
setHidden(true);
requestAnimationFrame(() => requestAnimationFrame(() => setHidden(false)));
}, 350);
return () => clearInterval(id);
}, []);
return (
<div className={styles.scene} style={vars}>
<p className={styles.label}>Auto-save</p>
<div className={`${styles.toast} ${hidden ? styles.hidden : ""}`}>{label}</div>
</div>
);
}09 · @starting-styleでロード時演出 — 計測 累積面積 2.515% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
デモの中で繰り返し登場しているのは、二重のrequestAnimationFrameでis-hiddenクラスを350ミリ秒ごとに付け外しする8行ほどのJSのおかげで、実際のサービスでトーストが一度だけ新しく現れる瞬間にはこのJS自体が不要です。
@supports (transition-behavior: allow-discrete) {
.toast {
transition: display .22s $ease-pop, opacity .22s $ease-pop, transform .22s $ease-pop;
}
.toast.is-hidden {
display: none;
opacity: 0;
transform: translateY(12px) scale(.92);
}
@starting-style {
.toast { opacity: 0; transform: translateY(12px) scale(.92); }
}
}
ヒーローセクションアニメーションはどこで壊れるか
何が問題か
ヒーローセクション 参考 を集めながら09番を作っているときに驚いた落とし穴が一つあります。@starting-styleは、あとからDOMに追加された要素だけでなく、ページを開いた瞬間にすでに存在していた要素の最初のスタイル計算にも効くという点です。ページを開いて繰り返し用のJSが一度も走っていない段階なのに、トーストがもういちど登場の遷移を自分から再生していました。@starting-styleは「新しく描画されたように見える」あらゆる瞬間に働き、そこにはページが描かれた最初のフレームも含まれるということです。
どう避けるか
最初の演出をコンポーネントの中へ移したときに起きる再実行の問題は、ライブラリなしのReact CSSアニメーションでマウント時点を基準に整理しました。
つまりこの繰り返し用JSは登場を「はじめて作る」コードではなく、CSSがすでに一度やってのけたことを「もう一度見せる」ためのコードに近いといえます。9つ分のファイルが入ったzipの解凍パスワードは5p7qvp7zで、渡されたファイルにそのまま入力すれば開きます。03番のbox-shadow、08番のclip-pathにも似た種類の落とし穴があり、GPUが合成だけで済ませられるtransform・opacityと違って毎フレーム塗り直しが必要なので、レイヤーを欲張って重ねるほど非力な端末で先に動きの重さが目立ちます。
アクセシビリティ — 9つがreduced-motionで残すもの
reduced-motion で残るもの
システムで動きを減らす設定がオンになっていると、9つ全部でanimationがまるごとnoneになり止まります。01・02・03・04・05・07・08のような装飾的な動きはそのまま止まっていれば十分ですが、09だけはtransitionも切って.toastを常に表示状態のまま残し、動きが苦手な人にも通知の内容そのものは欠けずに届くようにしてあります。
あわせて読みたい
ボタン自体の反応を集めたパターンはCSSボタンアニメーション、同じtranslateX(-50%)の無限スクロールをロゴ以外にも応用した例はCSSマーキー無限スクロールにまとめています。
FAQ — よくある質問
ファーストビュー アニメーション css だけで作れますか?
はい、9つ全部がCSSだけで動きます。唯一09番だけがデモを繰り返し再生するために8行ほどのJSを添えていますが、実際のサービスでトーストが一度新しく現れる瞬間にはこのJS自体が要りません。
@starting-styleはどのブラウザで使えますか?
Chrome・Edgeの117以降で使えます。MDNの@starting-styleに対応状況がまとまっていて、未対応のブラウザでは09番のデモが@supportsブロックの外に出て、遷移なしで常に表示された状態のまま残ります。
スクロールアニメーションなのに、実際にスクロールしなくても動くのはなぜですか?
07番は本番のサービスならscroll-timelineでスクロールの進み具合に直接結びつきますが、このデモはキャプチャとブラウザ間の互換性のために、同じ見た目を時間ベースの@keyframesループで代わりに再生しています。実際に組み込むときはMDNのscroll-driven animationsを参考にanimation-timeline: view()へ置き換えれば動きます。日付や申込ボタンまで載る最初の画面が要るなら、イベントページのテンプレートに完成したセクションがあります。









