CSS 히어로 섹션 애니메이션 9종
히어로 섹션은 사이트에 들어가자마자 가장 먼저 보이는 큰 화면 하나, 큰 제목과 버튼이 자리 잡은 그 첫인상을 말한다. AOS·GSAP 없이, JS 0줄로 그 화면에 움직임을 주는 CSS 애니메이션 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 헤드라인 순차 등장
- 02 배경 그라디언트 드리프트
- 03 CTA 펄스 글로우
- 04 목업 플로트
- 05 배지 슬라이드 인
- 06 마퀴 스트립
- 07 스크롤 시 텍스트가 뒤로 페이드
- 08 분할 텍스트 마스크 리빌
- 09 @starting-style 로드 진입
아홉 개는 인기순이 아니라 방문자가 화면을 여는 순간부터 겪는 순서로 놓았다. 열자마자 시선이 가는 헤드라인(01)에서 시작해 배경의 은은한 움직임(02)·시선이 멈추는 CTA 버튼(03)을 지나, 제품을 보여주는 목업(04)과 눈길을 끄는 배지(05)로 신뢰를 더한다. 그 아래로 스크롤하면 지나가는 파트너사 스트립(06)까지가 로드 직후의 순서고, 나머지 셋(07~09)은 사용자가 실제로 스크롤하거나 뭔가를 저장할 때 반응한다. 첫 화면을 여러 장으로 돌릴 생각이면 CSS 캐러셀이 그 틀을 만들고, 아래로 이어지는 이야기 순서를 보여 주려면 타임라인 애니메이션이 그다음 칸을 채웁니다.
01헤드라인 순차 등장
히어로 섹션 디자인에서 어디에 쓰나
헤드라인 세 줄이 한꺼번에 뜨지 않고 한 줄씩 늦게 등장한다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
줄마다 따로 @keyframes 를 두고 시작 지점을 10%·20%·30% 로 어긋나게 배치해, opacity 와 translateY 두 값만으로 순서를 만든다.
.hero__line { opacity: 0; transform: translateY(24px); }
.hero__line--1 { animation: line-in-1 $duration $easing infinite; } // $easing: $ease-spring
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
background-position 을 움직이는 방식과 달리 카드 크기가 바뀌어도 그라디언트가 다시 계산되지 않는다.
.hero::before {
content: '';
position: absolute;
inset: -60% -60%;
background: conic-gradient(from 0deg, #ff477e, #7b2ff7, #4cc9f0, #ff477e);
animation: drift $duration $easing infinite; // $easing: $ease-out
}
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
데모용 반복 재생과 별개로 실제 :hover·:focus-visible 에서도 같은 색으로 반응해, 루프를 꺼도 버튼 자체는 그대로 살아 있다.
.cta:hover { transform: translateY(-2px); }
.cta { animation: pulse $duration $easing infinite; } // $easing: $ease-pop
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
두 값 다 이동(transform)만 건드려서 레이아웃이 다시 계산될 일이 없다.
.mockup { animation: float $duration $easing infinite; } // $easing: $ease-spring
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
30%~65% 구간에 정지 상태를 넣어 뒀기 때문에 논스톱으로 왔다 갔다 하지 않고, 들어와서 잠깐 읽힐 시간을 준다.
.badge { animation: slide-in $duration $easing infinite; } // $easing: $ease-pop
@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마퀴 스트립
무슨 일이 일어나나
히어로 아래 연동사 알약을 두 벌 이어 붙인 트랙이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
간격은 flex 의 gap 이 아니라 알약마다 margin-right 로 준다 — gap 을 쓰면 세트 사이 이음새만 간격이 반 토막 나서 한 바퀴마다 픽셀이 튄다.
.marquee__track {
display: flex;
width: max-content;
animation: pill-scroll $duration $easing infinite; // $easing: linear
}
.marquee__pill { margin-right: 16px; flex-shrink: 0; } // gap 대신 margin-right
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
다만 브라우저 지원 차이가 아직 커서, 이 데모는 같은 모양을 @keyframes 반복 루프로 대신 보여준다.
.hero { animation: recede $duration $easing infinite; transform-origin: center top; } // $easing: $ease-out
@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분할 텍스트 마스크 리빌
어떻게 보이나
헤드라인이 왼쪽에서 오른쪽으로 쓸려 나오듯 드러난다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
clip-path: inset() 의 오른쪽 여백을 100%에서 0%로 좁히는 방식이고, 같은 박자로 밑줄이 scaleX 로 자라 같이 등장한다.
.headline__mask { clip-path: inset(0 100% 0 0); animation: reveal $duration $easing infinite; } // $easing: $ease-spring
@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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
@keyframes 도, 진입 애니메이션을 위한 JS 도 필요 없다 — 데모의 8줄짜리 JS 는 이 등장을 반복 재생하려고 넣은 것일 뿐, 실제 서비스에서 토스트가 한 번 새로 뜨는 순간엔 이 JS 없이도 그대로 작동한다.
@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); }
}
}
어디서 깨지는가 — @starting-style 은 이미 있던 요소에도 걸린다
무엇이 문제인가
히어로 섹션 ui 를 짜며 09번을 처음 만들 때는 @starting-style 이 나중에 DOM 에 추가되는 요소에만 걸릴 거라고 생각했다. 실측해 보니 아니었다 — 페이지를 열자마자, 반복 재생용 JS 가 단 한 번도 돌기 전에 토스트가 이미 진입 전환을 한 번 재생했다. @starting-style 은 "새로 렌더된 것처럼 보이는" 모든 순간에 적용되고, 여기엔 페이지가 막 그려진 첫 프레임도 포함된다.
어떻게 피하나
첫 화면 연출을 컴포넌트 안으로 옮길 때 생기는 재실행 문제는 라이브러리 없는 리액트 CSS 애니메이션에서 마운트 시점 기준으로 정리했습니다.
그래서 반복 재생 JS 는 등장을 "처음 만드는" 코드가 아니라 이미 CSS 가 한 번 해낸 걸 "다시 보여주는" 코드에 가깝다. 이 9개 파일이 들어 있는 zip 의 압축 비밀번호는 5p7qvp7z 이고, 받은 파일에 그대로 입력하면 풀린다. 03번의 box-shadow, 08번의 clip-path 도 비슷한 계열의 함정을 갖고 있다 — 둘 다 GPU 가 합성만 하는 transform·opacity 와 달리 매 프레임 다시 칠해야 해서, 레이어를 욕심내 겹칠수록 저사양 기기에서 먼저 티가 난다.
접근성
reduced-motion 에서 남는 것
reduced-motion 을 켠 환경에서는 아홉 개 모두 animation 이 통째로 꺼지도록 맞췄다. 01번은 줄이 곧바로 다 보이는 상태로, 02·04·07번은 움직임이 멈춘 정지 화면으로, 05·08번은 배지·헤드라인이 이미 다 드러난 최종 상태로 남는다.
그 밖에 챙길 것
09번은 전환(transition)만 꺼서 토스트가 항상 보이는 상태를 유지한다 — 움직임이 불편한 사람도 정보 자체는 놓치지 않는다.
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() 로 바꾸면 된다. 나머지 CSS 애니메이션 모음은 사이트 검색 에서, 이 블로그가 데모를 만드는 방식은 소개 페이지 에서 볼 수 있다. 행사 페이지처럼 날짜와 신청 버튼이 함께 붙는 첫 화면이 필요하면 이벤트 페이지 템플릿에 완성형 섹션이 있습니다.









