9 CSS Hero Section Animations You Can Copy-Paste
A hero section is the big banner filling a landing page's first screen — headline, text, button.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Headline stagger in
- 02 Gradient background drift
- 03 CTA pulse glow
- 04 Mockup float
- 05 Badge slide in
- 06 Marquee strip
- 07 Text fades behind on scroll
- 08 Split text mask reveal
- 09 @starting-style load-in
These nine are ordered by what a visitor's eye actually meets on a fresh load, not by popularity. 01 through 03 own the very first screen — the headline, the background drifting behind it, and the CTA that has to keep holding attention. 04 and 05 build trust with a product mockup and a badge, 06 is the partner-logo strip most hero sections tuck just under the fold, and the last three (07-09) react to something happening — scrolling past, or a toast loading in. Rotating that first screen through several slides is framed by the pure CSS carousels, and showing the order of the story that follows is filled in by the timeline animations.
01Headline stagger in
What you see
The three lines of a hero headline don't land together — each one fades and lifts into place a beat after the one before it, like someone typing out the pitch line by line.

<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 · Headline stagger in — Measured area 13.299% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
Every line has its own @keyframes, and only the entrance point (10%, 20%, 30%) shifts between them, so the whole stagger runs on nothing but opacity and translateY.
.hero__line { opacity: 0; transform: translateY($sp-6); }
.hero__line--1 { animation: line-in-1 $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); }
}
02Gradient background drift
Where a css hero background animation fits
Behind the card sits a conic-gradient layer more than twice the card's own size, held in place only by overflow: hidden on the card that clips it.

<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 · Gradient background drift — Measured area 23.185% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
Just translate and rotate move it — never background-position — so the gradient never has to be recalculated as it drifts.
.hero::before {
content: '';
position: absolute;
inset: -60% -60%;
background: conic-gradient(from 0deg, #ff477e, #7b2ff7, #4cc9f0, #ff477e);
opacity: .55;
}
.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); }
}
03CTA pulse glow
What moves
The button breathes: scale and the box-shadow glow's blur radius and opacity grow and shrink together on the same loop.

<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 pulse glow — Measured area 8.864% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
That loop is decoration only — the button still lifts on a genuine :hover and outlines on :focus-visible, each running its own, much quicker transition underneath.
.cta {
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; }
.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); }
}
04Mockup float
The moment it fires
The whole mockup card — browser-chrome dots and all — drifts up and tilts a fraction of a degree, then settles back, reading as floating instead of sitting flat on the page.

<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 · Mockup float — Measured area 16.142% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
Both values live entirely inside transform, so the float never forces a layout recalculation.
.mockup { overflow: hidden; }
.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); }
}
05Badge slide in
What the user notices
A NEW badge slides in from the side, holds still long enough to actually be read, then slides back out and repeats.

<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 · Badge slide in — Measured area 1.14% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
That hold comes from a flat stretch in the keyframes — 30% to 65% sit at the same value — rather than any pause added to the animation itself.
.badge {
opacity: 0;
transform: translateX($sp-10);
}
.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); }
}
06Marquee strip
Where this shows up
Below the hero, a row of partner-logo pills scrolls sideways without a visible end, because the row is actually two identical sets of pills glued together and moved exactly half their combined width.

<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 · Marquee strip — Measured area 7.556% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Spacing between pills comes from margin-right, not flex gap — gap would only widen the seam between the two sets, and the loop would visibly jump once a lap.
.marquee__track {
display: flex;
width: max-content;
animation: pill-scroll $duration $easing infinite;
}
.marquee__pill { margin-right: $sp-4; flex-shrink: 0; }
@keyframes pill-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
07Text fades behind on scroll
What happens on screen
In production, a real scroll-timeline would tie this headline's scale, opacity, and position directly to how far the page has scrolled.

<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 · Text fades behind on scroll — Measured area 13.056% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
Browser support for that still varies enough that this demo swaps in a plain, looping @keyframes animation instead, so the capture looks the same no matter which browser opens it.
.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); }
}
08Split text mask reveal
What it looks like
The headline looks like it's being wiped into view left to right, with an underline growing on the exact same beat underneath it.

<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 · Split text mask reveal — Measured area 2.719% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
clip-path: inset() does the wipe by narrowing its right-hand value from 100% down to 0%, while the underline grows alongside it with a plain scaleX.
.headline__mask { clip-path: inset(0 100% 0 0); }
.headline__underline { transform: scaleX(0); transform-origin: left center; }
.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); }
}
09@starting-style load-in
When to use it
A toast can go from display: none to fully visible with a genuine entrance transition — no @keyframes, no animation logic in JavaScript — just @starting-style paired with transition-behavior: allow-discrete set on the display property itself. The only JavaScript anywhere in this set of nine lives here, and it exists purely to replay that entrance every 350ms for the demo capture, not to make a real toast's first appearance work.

<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 load-in — Measured area 2.515% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
In the actual file this loop sits inside a small function that waits 300ms before its first replay and then repeats.
@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);
}
@starting-style {
.toast { opacity: 0; transform: translateY($sp-3) scale(.92); }
}
}
function loop() {
t.classList.add('is-hidden');
requestAnimationFrame(function () {
requestAnimationFrame(function () { t.classList.remove('is-hidden'); });
});
}
setInterval(loop, 350);
Where does @starting-style break on a page that was never freshly loaded?
What goes wrong
When item 09 of this css hero animation set was first built, the assumption was that @starting-style only fires for elements added to the page after it has already rendered. The real render disagreed: the toast played its entrance once on its own, before the replay script had run a single time, because @starting-style also applies to whatever was already sitting in the document at that very first style calculation — not only to later insertions. The replay loop that follows isn't what creates the entrance at all; it just re-triggers something CSS had already done by itself.
How to avoid it
The re-run problem that appears once an opening sequence moves inside a component is sorted out, mount by mount, in CSS animations in React without a library.
Two other items in this set pay a related, quieter cost for stepping outside transform/opacity: 03's box-shadow glow and 08's clip-path wipe both force a repaint on every frame instead of a cheap composite — a trade each effect makes for a look transform alone can't produce. All nine files — the SCSS above and the React versions — sit behind one zip that opens with 5p7qvp7z, typed exactly as it appears in this sentence.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Tighter stagger | Item 01's entrance points (10% / 20% / 30%) tightened to (5% / 10% / 15%) | Lines arrive almost together instead of one at a time |
| Bigger float | Item 04's translateY(-$sp-4) (16px) raised to translateY(-32px) |
The mockup bobs like it's on water instead of breathing gently |
| Wider glow | Item 03's box-shadow spread $sp-8 (32px) widened to 56px |
The CTA reads as a more urgent pulse instead of a calm one |
Accessibility
What reduced motion leaves
With prefers-reduced-motion: reduce, all nine turn their animation off, and most simply land on an end state: 01 and 05 show every headline line and the badge already in place, 04 and 07 hold their resting transform, and 08 leaves the headline fully unmasked with the underline already grown. 02's gradient just stops turning wherever it happened to be mid-loop; 03 swaps its pulsing glow for one fixed, softer shadow rather than turning the glow off outright, so the button still reads as interactive without the motion. 06's marquee track stops at translateX(0), holding on the first set of pills — nothing is lost, since the row was only ever duplicated to loop, not to show two different sets of logos.
What else to check
09 is the odd one out: reduced motion strips its transition, not its animation, so the demo's own replay script still toggles the toast every 350ms, just as an instant flash instead of a slide — the one item here that doesn't fully go still. More CSS animation sets that reuse this project's building blocks live in the marquee post item 06 borrows its scroll structure from, and the rest of what's published sits in the CSS category hub.
FAQ
Do I need JavaScript for these hero section animation ideas?
No — eight of the nine run on CSS alone. The only JavaScript in the set is a short replay loop behind item 09, and it exists only to repeat the entrance for this demo's capture, not to make a real toast's first appearance work.
Why does my scroll-linked hero effect play even when I'm not scrolling?
Item 07 shows what a real scroll-timeline effect would look like, but browser support for animation-timeline: view() and scroll() still varies enough that this demo substitutes a timed @keyframes loop instead. Wire the same scale, opacity, and translateY values to animation-timeline: view() directly once the target browsers support it — see MDN's scroll-driven animations guide.
Will box-shadow and clip-path animations run as smoothly as the rest?
Not quite — transform and opacity are the only two properties a browser can animate by compositing existing layers, so items 03 (box-shadow) and 08 (clip-path) force a repaint on every frame instead. Both still earn their keep here, since neither a glow nor a wipe reveal can be built from transform or opacity alone. For a first screen that also has to carry a date and a signup button, the event landing page templates hold the finished sections.









