Hero Section Design: 9 Patterns, No JS Libraries
Hero section design is the layout of the first screen a visitor lands on, and these nine patterns build that screen from plain HTML and CSS with no JS
01 hero-split-product-scroll
02 hero-center-stack-badge
03 hero-kinetic-headline
04 hero-gradient-glass-card
05 hero-product-zoom-hotspot
06 hero-stat-counter-roll
07 hero-scroll-crane-fold
08 hero-split-slider
09 hero-minimal-logo-pulse
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Split product hero
- 02 Center-stack badge hero
- 03 Word-by-word headline
- 04 Gradient glass hero
- 05 Product zoom hotspot hero
- 06 Stat counter hero
- 07 Scroll-folding hero
- 08 Split slider hero
- 09 Minimal logo hero
The nine landing page hero section patterns run in the order a first screen is actually read, not in order of flashiness. First the split between words and product (01), then the announcement pill above the headline (02), then the headline selling one sentence (03), then the ground underneath it when there are no photos (04), then the features being pointed at (05), then the numbers backing the claim (06). Once the screen itself is settled: the seam where it hands the visitor to the page body (07), the rotation when several subjects deserve the same spot (08), and what remains when everything dispensable is deleted (09). This edition covers hero layout, whereas the earlier hero animations collection covered how an existing hero moves. In page terms these nine open the door, footer design patterns close it, and the testimonial wall in between belongs to testimonial section design. Each demo was rendered to 24 frames at 480×300 and measured frame by frame; every count and percentage below is read from those renders, and not one of the nine spilled out of its frame at desktop or phone width.
01Split product hero
What this hero section design ui shows
An eyebrow pill, a title, a lead line, and two buttons stand on the left while three product cards rise from below in turn, .12s and .24s apart, and the scroll cue at the foot keeps bouncing on its own smaller arc. The layers rise fast over the first 18% of the loop and lower slowly after 60%, and every delayed layer carries animation-fill-mode: backwards so the finished stack never flashes at frame zero.

<main class="stage">
<section class="hs is-demo" aria-label="Product intro first screen">
<div class="hs__copy">
<span class="hs__eyebrow">New version is out</span>
<h1 class="hs__title">One screen for the whole team day</h1>
<p class="hs__lead">See scattered schedules and notes in one place.</p>
<div class="hs__actions">
<a class="hs__btn hs__btn--primary" href="#">Start for free</a>
<a class="hs__btn hs__btn--ghost" href="#">Take a tour</a>
</div>
</div>
<!-- 제품 도형 스택 — 뒤에서부터 겹쳐진 둥근 사각형 세 장. 맨 앞 장만 화면을 흉내 낸다 -->
<div class="hs__art" aria-hidden="true">
<span class="hs__card hs__card--c"></span>
<span class="hs__card hs__card--b"></span>
<span class="hs__card hs__card--a">
<span class="hs__bar hs__bar--t"></span>
<span class="hs__bar hs__bar--m"></span>
<span class="hs__bar hs__bar--k"></span>
</span>
</div>
<button class="hs__cue" type="button">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M208,96l-80,80L48,96Z" opacity="0.2"/><path d="M215.39,92.94A8,8,0,0,0,208,88H48a8,8,0,0,0-5.66,13.66l80,80a8,8,0,0,0,11.32,0l80-80A8,8,0,0,0,215.39,92.94ZM128,164.69,67.31,104H188.69Z"/></svg>
<span>Scroll down</span>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 크림 위 제목 17.10:1
$hr-paper: $stage-paper; // 앞 카드 면 · 바이올렛 면 위 글자 6.14:1
$hr-cream: #fdf7ec; // 무대
$hr-stone: #56505e; // 크림 위 보조 글자 7.28:1
$hr-yellow: $stage-yellow; // 머리말 배지 면 (잉크 글자 12.63:1)
$hr-violet: #9791c9; // 주 버튼 면 (흰 글자 6.14:1) · 크림 위 5.75:1
$hr-lilac: #d8d6eb; // 뒤 두 장의 면
$duration: $dur-loop; // 도형 세 장이 차례로 올라왔다 다시 내려가는 한 바퀴
$easing: $ease-spring; // 층이 올라와 자리 잡을 때의 감속
$color: $hr-violet; // 이 항목의 강조색
@include stage($hr-cream);
// 스플릿 — 왼쪽 글자 열, 오른쪽 도형 열, 아래 한 줄에 스크롤 안내.
// 좁은 화면에서도 두 열을 유지한다(세로로 쌓으면 200px 무대를 넘는다)
.hs {
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: $sp-4;
row-gap: $sp-3;
width: clamp(288px, 94vw, 432px);
}
.hs__copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
min-width: 0;
}
.hs__eyebrow {
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $hr-yellow;
color: $hr-ink;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.2;
letter-spacing: .01em;
}
.hs__title {
margin: 0;
color: $hr-ink;
font-weight: 800;
font-size: clamp(16px, 4.2vw, 26px);
line-height: 1.15;
letter-spacing: -.02em;
}
.hs__lead {
margin: 0;
color: $hr-stone;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
}
.hs__actions {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
display: inline-flex;
align-items: center;
padding: $sp-2 $sp-3;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hs__btn--primary { border-color: $color; background: $color; color: $hr-paper; }
.hs__btn--ghost { border-color: $hr-ink; color: $hr-ink; }
// 터치·키보드에서도 같은 표시 — 호버 전용 강조를 만들지 않는다
.hs__btn:focus-visible,
.hs__cue:focus-visible {
outline: 2px solid $color;
outline-offset: 4px;
}
// ── 제품 도형 스택 ────────────────────────────────────────
// --rise 는 층이 아래에서 올라오는 거리. 무대 높이에 맞춰 좁은 화면에서 줄인다
.hs__art {
--rise: 22px;
position: relative;
width: 156px;
height: 126px;
}
.hs__card {
position: absolute;
width: 130px;
height: 72px;
border-radius: $r-card;
background: $hr-lilac;
}
.hs__card--c { left: 26px; top: 0; }
.hs__card--b { left: 13px; top: 16px; background: $color; }
// 맨 앞 장만 흰 카드 — 회색·라일락 막대로 화면을 흉내 낸다
.hs__card--a {
left: 0;
top: 32px;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
box-sizing: border-box;
padding: $sp-3;
border: 2px solid $hr-ink;
background: $hr-paper;
}
.hs__bar { border-radius: $r-xs; background: $hr-lilac; }
.hs__bar--t { width: 56%; height: 8px; background: $hr-ink; }
.hs__bar--m { width: 88%; height: 6px; }
.hs__bar--k { width: 34%; height: 8px; border-radius: $r-pill; background: $color; }
// ── 스크롤 안내 ───────────────────────────────────────────
.hs__cue {
grid-column: 1 / -1;
justify-self: center;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border: 2px solid $hr-ink;
border-radius: $r-pill;
background: $hr-paper;
color: $hr-ink;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.3;
cursor: pointer;
}
// ── 자동 재생 ─────────────────────────────────────────────
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다.
// 지연이 걸린 층은 animation-fill-mode: backwards 가 없으면 0프레임에 완성 상태가 한 번 번쩍인다
.hs.is-demo .hs__card {
animation-name: hsRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hs.is-demo .hs__card--b { animation-delay: .12s; }
.hs.is-demo .hs__card--a { animation-delay: .24s; }
.hs.is-demo .hs__cue {
animation-name: hsCue;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// 올라오기는 빠르게(0~18%), 내려가기는 느리게(60~88%) — poster 는 "앞 장과 가장 크게
// 달라진 장"이라 등장 쪽 변화량이 커야 대표 이미지가 자리 잡은 장면이 된다.
// 복귀는 88% 안에 끝낸다 (24프레임의 마지막 장이 95.8% 지점)
@keyframes hsRise {
0% { transform: translateY(var(--rise)); }
18%, 60% { transform: translateY(0); }
88%, 100% { transform: translateY(var(--rise)); }
}
@keyframes hsCue {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-6px); }
40% { transform: translateY(0); }
60% { transform: translateY(-3px); }
80% { transform: translateY(0); }
}
// 스크롤 안내를 눌렀을 때의 일회성 등장 (JS 가 클래스를 붙였다 animationend 에서 뗀다)
.hs.is-replay .hs__card {
animation-name: hsEnter;
animation-duration: $dur-enter;
animation-timing-function: $easing;
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.hs.is-replay .hs__card--b { animation-delay: .1s; }
.hs.is-replay .hs__card--a { animation-delay: .2s; }
@keyframes hsEnter {
from { transform: translateY(var(--rise)); }
to { transform: translateY(0); }
}
// 좁은 화면 — 두 열을 유지한 채 도형과 글자만 줄인다. 여기서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.hs { column-gap: $sp-2; row-gap: $sp-2; width: 100%; }
.hs__copy { gap: $sp-1; }
.hs__art { --rise: 14px; width: 104px; height: 86px; }
.hs__card { width: 88px; height: 48px; }
.hs__card--c { left: 16px; }
.hs__card--b { left: 8px; top: 12px; }
.hs__card--a { top: 24px; gap: $sp-1; padding: $sp-2; }
.hs__bar--t { height: 6px; }
.hs__bar--m { height: 4px; }
.hs__bar--k { height: 6px; }
.hs__btn { padding: $sp-1 $sp-2; }
.hs__cue { padding: 0 $sp-2; }
.hs__cue svg { width: 11px; height: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .hs.is-demo .hs__card (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 카드·안내 글자는 그대로 남는다
.hs__card, .hs__cue { animation: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fdf7ec;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 16px;
row-gap: 12px;
width: clamp(288px, 94vw, 432px);
}
.hs__copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.hs__eyebrow {
padding: 4px 8px;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.2;
letter-spacing: 0.01em;
}
.hs__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(16px, 4.2vw, 26px);
line-height: 1.15;
letter-spacing: -0.02em;
}
.hs__lead {
margin: 0;
color: #56505e;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
}
.hs__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border: 2px solid transparent;
border-radius: 12px;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hs__btn--primary {
border-color: #9791c9;
background: #9791c9;
color: #ffffff;
}
.hs__btn--ghost {
border-color: #17141a;
color: #17141a;
}
.hs__btn:focus-visible,
.hs__cue:focus-visible {
outline: 2px solid #9791c9;
outline-offset: 4px;
}
.hs__art {
--rise: 22px;
position: relative;
width: 156px;
height: 126px;
}
.hs__card {
position: absolute;
width: 130px;
height: 72px;
border-radius: 16px;
background: #d8d6eb;
}
.hs__card--c {
left: 26px;
top: 0;
}
.hs__card--b {
left: 13px;
top: 16px;
background: #9791c9;
}
.hs__card--a {
left: 0;
top: 32px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
box-sizing: border-box;
padding: 12px;
border: 2px solid #17141a;
background: #ffffff;
}
.hs__bar {
border-radius: 4px;
background: #d8d6eb;
}
.hs__bar--t {
width: 56%;
height: 8px;
background: #17141a;
}
.hs__bar--m {
width: 88%;
height: 6px;
}
.hs__bar--k {
width: 34%;
height: 8px;
border-radius: 999px;
background: #9791c9;
}
.hs__cue {
grid-column: 1/-1;
justify-self: center;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.3;
cursor: pointer;
}
.hs.is-demo .hs__card {
animation-name: hsRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hs.is-demo .hs__card--b {
animation-delay: 0.12s;
}
.hs.is-demo .hs__card--a {
animation-delay: 0.24s;
}
.hs.is-demo .hs__cue {
animation-name: hsCue;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hsRise {
0% {
transform: translateY(var(--rise));
}
18%, 60% {
transform: translateY(0);
}
88%, 100% {
transform: translateY(var(--rise));
}
}
@keyframes hsCue {
0%, 100% {
transform: translateY(0);
}
20% {
transform: translateY(-6px);
}
40% {
transform: translateY(0);
}
60% {
transform: translateY(-3px);
}
80% {
transform: translateY(0);
}
}
.hs.is-replay .hs__card {
animation-name: hsEnter;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.hs.is-replay .hs__card--b {
animation-delay: 0.1s;
}
.hs.is-replay .hs__card--a {
animation-delay: 0.2s;
}
@keyframes hsEnter {
from {
transform: translateY(var(--rise));
}
to {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hs {
column-gap: 8px;
row-gap: 8px;
width: 100%;
}
.hs__copy {
gap: 4px;
}
.hs__art {
--rise: 14px;
width: 104px;
height: 86px;
}
.hs__card {
width: 88px;
height: 48px;
}
.hs__card--c {
left: 16px;
}
.hs__card--b {
left: 8px;
top: 12px;
}
.hs__card--a {
top: 24px;
gap: 4px;
padding: 8px;
}
.hs__bar--t {
height: 6px;
}
.hs__bar--m {
height: 4px;
}
.hs__bar--k {
height: 6px;
}
.hs__btn {
padding: 4px 8px;
}
.hs__cue {
padding: 0 8px;
}
.hs__cue svg {
width: 11px;
height: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__card, .hs__cue {
animation: none !important;
transform: none !important;
}
}(function () {
var root = document.querySelector('.hs');
var cue = root.querySelector('.hs__cue');
var front = root.querySelector('.hs__card--a');
function stopLoop() { root.classList.remove('is-demo'); }
// 스크롤 안내를 누르면 스택 등장만 한 번 다시 재생한다.
// 클래스를 뗐다 붙이는 사이에 리플로를 한 번 일으켜야 애니메이션이 다시 걸린다.
function replay() {
root.classList.remove('is-replay');
void root.offsetWidth;
root.classList.add('is-replay');
}
cue.addEventListener('click', function () {
stopLoop();
replay();
});
// 맨 앞 장이 가장 늦게 도착하므로 그 끝에서 일회성 클래스를 뗀다
front.addEventListener('animationend', function () {
root.classList.remove('is-replay');
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroSplitProduct.module.scss";
/* ambient 에 DOM 이벤트 타입이 없어 파일 안에 필요한 만큼만 선언한다 */
type AnimEvt = { currentTarget: { classList: { remove(c: string): void } } };
type Box<T> = { current: T };
export type HeroSplitProductProps = {
eyebrow?: string;
title?: string;
lead?: string;
primary?: string;
ghost?: string;
cue?: string;
/** 스크린 리더가 읽을 섹션 이름 */
sectionLabel?: string;
/** 도형 세 장이 차례로 올라왔다 다시 내려가는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 이 항목의 강조색 — 주 버튼 면·가운데 카드 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CaretDownIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M208,96l-80,80L48,96Z" opacity="0.2" />
<path d="M215.39,92.94A8,8,0,0,0,208,88H48a8,8,0,0,0-5.66,13.66l80,80a8,8,0,0,0,11.32,0l80-80A8,8,0,0,0,215.39,92.94ZM128,164.69,67.31,104H188.69Z" />
</svg>
);
export default function HeroSplitProduct({
eyebrow = "새 버전 공개",
title = "한 화면에 모이는 팀의 하루",
lead = "흩어진 일정과 메모를 한곳에서 봅니다.",
primary = "무료로 시작하기",
ghost = "기능 둘러보기",
cue = "아래로 보기",
sectionLabel = "제품 소개 첫 화면",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#5b4bd6",
}: HeroSplitProductProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [replay, setReplay] = useState<number>(0);
const rootRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 스크롤 안내를 누르면 스택 등장만 한 번 다시 재생한다.
key 를 바꿔 카드를 새로 마운트해야 일회성 애니메이션이 다시 걸린다 */
const onCue = (): void => {
setLoop(false);
setReplay((n: number) => n + 1);
};
const cls = [styles.hs, loop ? styles.isDemo : "", replay > 0 ? styles.isReplay : ""].filter(Boolean).join(" ");
return (
<section
className={cls}
style={vars}
aria-label={sectionLabel}
ref={rootRef}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.copy}>
<span className={styles.eyebrow}>{eyebrow}</span>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead}>{lead}</p>
<div className={styles.actions}>
<a className={`${styles.btn} ${styles.btnPrimary}`} href="#">{primary}</a>
<a className={`${styles.btn} ${styles.btnGhost}`} href="#">{ghost}</a>
</div>
</div>
{/* 제품 도형 스택 — 뒤에서부터 겹쳐진 둥근 사각형 세 장. 맨 앞 장만 화면을 흉내 낸다 */}
<div className={styles.art} aria-hidden="true" key={replay}>
<span className={`${styles.card} ${styles.cardC}`} />
<span className={`${styles.card} ${styles.cardB}`} />
<span
className={`${styles.card} ${styles.cardA}`}
onAnimationEnd={(e: AnimEvt) => { e.currentTarget.classList.remove(styles.isReplay); }}
>
<span className={`${styles.bar} ${styles.barT}`} />
<span className={`${styles.bar} ${styles.barM}`} />
<span className={`${styles.bar} ${styles.barK}`} />
</span>
</div>
<button className={styles.cue} type="button" onClick={onCue}>
<CaretDownIcon />
<span>{cue}</span>
</button>
</section>
);
}01 · Split product hero — Measured area 11.617% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Reach for it on the first screen of a product landing page; it moved in 22 of 24 frames (11.672% cumulative changed area, intensity 95.9), and at 320px it shrinks the art and the 22px rise distance rather than stacking the two columns.
.hs__art {
--rise: 22px;
position: relative;
width: 156px;
height: 126px;
}
.hs.is-demo .hs__card {
animation-name: hsRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hs.is-demo .hs__card--b { animation-delay: .12s; }
.hs.is-demo .hs__card--a { animation-delay: .24s; }
@keyframes hsRise {
0% { transform: translateY(var(--rise)); }
18%, 60% { transform: translateY(0); }
88%, 100% { transform: translateY(var(--rise)); }
}
02Center-stack badge hero
What happens on screen
A sheen crosses the news badge once per loop, and below the headline, its lead line, and a solid-and-line button pair the four trust tiles lift 7px and settle, .14s apart. The sheen ends its run parked at translateX(320%), outside an overflow: hidden pill, so the rewind to the left edge never becomes visible.

<main class="stage">
<section class="hb is-demo" aria-label="Launch announcement first screen">
<p class="hb__badge">
<span class="hb__sheen" aria-hidden="true"></span>
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
<span class="hb__badge-t">Update of the week</span>
</p>
<h1 class="hb__title">New features land here first</h1>
<p class="hb__lead">Sign up and try them before the public release.</p>
<div class="hb__actions">
<a class="hb__btn hb__btn--solid" href="#">Request access</a>
<a class="hb__btn hb__btn--line" href="#">See what changed</a>
</div>
<!-- Trust marks — 실제 로고가 아니라 둥근 사각형 안에 일반 아이콘 + 일반명사 라벨 -->
<ul class="hb__trust" aria-label="Trust marks">
<li class="hb__mark hb__mark--1">
<span class="hb__tile">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor"><path d="M128,129.09V232a8,8,0,0,1-3.84-1l-88-48.16a8,8,0,0,1-4.16-7V80.2a8,8,0,0,1,.7-3.27Z" opacity="0.2"/><path d="M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32h0l80.34,44L128,120,47.66,76ZM40,90l80,43.78v85.79L40,175.82Zm96,129.57V133.82L216,90v85.78Z"/></svg>
</span>
<span class="hb__ml">Blocks</span>
</li>
<li class="hb__mark hb__mark--2">
<span class="hb__tile">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor"><path d="M128,112a36,36,0,1,1,36-36A36,36,0,0,1,128,112Zm60,24a36,36,0,1,0,36,36A36,36,0,0,0,188,136ZM68,136a36,36,0,1,0,36,36A36,36,0,0,0,68,136Z" opacity="0.2"/><path d="M172,76a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,76Zm-44,28a28,28,0,1,1,28-28A28,28,0,0,1,128,104Zm60,24a44,44,0,1,0,44,44A44.05,44.05,0,0,0,188,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,188,200ZM68,128a44,44,0,1,0,44,44A44.05,44.05,0,0,0,68,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,68,200Z"/></svg>
</span>
<span class="hb__ml">Links</span>
</li>
<li class="hb__mark hb__mark--3">
<span class="hb__tile">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor"><path d="M160,128a32,32,0,1,1-32-32A32,32,0,0,1,160,128Z" opacity="0.2"/><path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z"/></svg>
</span>
<span class="hb__ml">Accuracy</span>
</li>
<li class="hb__mark hb__mark--4">
<span class="hb__tile">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor"><path d="M232,56v64L168,56Z" opacity="0.2"/><path d="M232,48H168a8,8,0,0,0-5.66,13.66L188.69,88,136,140.69l-34.34-34.35a8,8,0,0,0-11.32,0l-72,72a8,8,0,0,0,11.32,11.32L96,123.31l34.34,34.35a8,8,0,0,0,11.32,0L200,99.31l26.34,26.35A8,8,0,0,0,240,120V56A8,8,0,0,0,232,48Zm-8,52.69L187.31,64H224Z"/></svg>
</span>
<span class="hb__ml">Growth</span>
</li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 옐로 위 글자 12.63:1 · 알약과 타일의 면
$hr-paper: $stage-paper; // 잉크 면 위 글자·아이콘 18.24:1
$hr-yellow: $stage-yellow; // 무대
$hr-violet: #9791c9; // 포커스 링 (옐로 위 3.28:1 — 글자로는 쓰지 않는다)
$duration: $dur-loop; // 빛줄기가 한 번 지나가고 신뢰 표시 넷이 차례로 들리는 한 바퀴
$easing: $ease-out; // 빛줄기의 등속에 가까운 흐름
$color: $hr-ink; // 옐로 무대 위 글자는 전부 잉크 (흰 글자는 1.44:1 이라 금지)
@include stage($hr-yellow);
// 센터 스택 — 배지 · 제목 · 설명 · 버튼 쌍 · 신뢰 표시가 한 축에 쌓인다
.hb {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: clamp(288px, 94vw, 432px);
text-align: center;
}
// ── 새 소식 배지 — 빛줄기가 지나갈 수 있게 overflow 를 닫는다 ──
.hb__badge {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $hr-paper;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.3;
}
.hb__sheen {
position: absolute;
left: 0;
top: 0;
width: 40%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, .62) 50%, rgba(255, 255, 255, 0) 100%);
transform: translateX(-160%);
}
.hb__title {
margin: 0;
color: $color;
font-weight: 800;
font-size: clamp(16px, 4.4vw, 27px);
line-height: 1.16;
letter-spacing: -.02em;
}
.hb__lead {
margin: 0;
color: $color;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
opacity: .78;
}
.hb__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-2;
}
.hb__btn {
display: inline-flex;
align-items: center;
padding: $sp-2 $sp-3;
border: 2px solid $color;
border-radius: $r-control;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hb__btn--solid { background: $color; color: $hr-paper; }
.hb__btn--line { color: $color; }
// 터치·키보드에서도 같은 표시
.hb__btn:focus-visible {
outline: 2px solid $hr-violet;
outline-offset: 4px;
}
// ── 신뢰 표시 넷 — 실제 로고가 아니라 일반 도형 + 일반명사 라벨 ──
.hb__trust {
display: flex;
justify-content: center;
gap: $sp-4;
margin: 0;
padding: 0;
list-style: none;
}
.hb__mark {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
}
.hb__tile {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: $r-control;
background: $color;
color: $hr-paper;
}
.hb__ml {
color: $color;
font-weight: 700;
font-size: clamp(8px, 1.8vw, 10px);
line-height: 1.2;
}
// ── 자동 재생 ─────────────────────────────────────────────
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다.
// 지연이 걸린 타일에는 animation-fill-mode: backwards 가 반드시 필요하다
.hb.is-demo .hb__sheen {
animation-name: hbSheen;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.hb.is-demo .hb__tile {
animation-name: hbLift;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hb.is-demo .hb__mark--2 .hb__tile { animation-delay: .14s; }
.hb.is-demo .hb__mark--3 .hb__tile { animation-delay: .28s; }
.hb.is-demo .hb__mark--4 .hb__tile { animation-delay: .42s; }
// 빛줄기는 알약 밖(오른쪽)에서 멈춰 있다가 다음 바퀴에 왼쪽으로 되돌아간다 —
// overflow: hidden 이 닫고 있으므로 되감기가 화면에 보이지 않는다
@keyframes hbSheen {
0% { transform: translateX(-160%); }
70%, 100% { transform: translateX(320%); }
}
// 들렸다 앉기 — 드는 쪽이 빠르고 앉는 쪽이 느려야 대표 이미지가 들린 장면이 된다.
// 복귀는 88% 안에 끝낸다 (24프레임의 마지막 장이 95.8% 지점)
@keyframes hbLift {
0% { transform: translateY(0); }
16%, 46% { transform: translateY(-7px); }
88%, 100% { transform: translateY(0); }
}
// 좁은 화면 — 한 축을 유지한 채 글자·타일만 줄인다. 여기서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.hb { gap: $sp-1; width: 100%; }
.hb__badge { padding: 0 $sp-2; }
.hb__badge svg { width: 11px; height: 11px; }
.hb__btn { padding: $sp-1 $sp-2; }
.hb__trust { gap: $sp-2; }
.hb__tile { width: 24px; height: 24px; border-radius: $r-sm; }
.hb__tile svg { width: 13px; height: 13px; }
}
@media (prefers-reduced-motion: reduce) {
// .hb.is-demo .hb__tile (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 배지·타일·라벨은 그대로 남는다
.hb__sheen, .hb__tile { animation: none !important; }
.hb__sheen { opacity: 0 !important; }
}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);
}
.hb {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: clamp(288px, 94vw, 432px);
text-align: center;
}
.hb__badge {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.3;
}
.hb__sheen {
position: absolute;
left: 0;
top: 0;
width: 40%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.62) 50%, rgba(255, 255, 255, 0) 100%);
transform: translateX(-160%);
}
.hb__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(16px, 4.4vw, 27px);
line-height: 1.16;
letter-spacing: -0.02em;
}
.hb__lead {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
opacity: 0.78;
}
.hb__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.hb__btn {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border: 2px solid #17141a;
border-radius: 12px;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hb__btn--solid {
background: #17141a;
color: #ffffff;
}
.hb__btn--line {
color: #17141a;
}
.hb__btn:focus-visible {
outline: 2px solid #9791c9;
outline-offset: 4px;
}
.hb__trust {
display: flex;
justify-content: center;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.hb__mark {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.hb__tile {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 12px;
background: #17141a;
color: #ffffff;
}
.hb__ml {
color: #17141a;
font-weight: 700;
font-size: clamp(8px, 1.8vw, 10px);
line-height: 1.2;
}
.hb.is-demo .hb__sheen {
animation-name: hbSheen;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.hb.is-demo .hb__tile {
animation-name: hbLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hb.is-demo .hb__mark--2 .hb__tile {
animation-delay: 0.14s;
}
.hb.is-demo .hb__mark--3 .hb__tile {
animation-delay: 0.28s;
}
.hb.is-demo .hb__mark--4 .hb__tile {
animation-delay: 0.42s;
}
@keyframes hbSheen {
0% {
transform: translateX(-160%);
}
70%, 100% {
transform: translateX(320%);
}
}
@keyframes hbLift {
0% {
transform: translateY(0);
}
16%, 46% {
transform: translateY(-7px);
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hb {
gap: 4px;
width: 100%;
}
.hb__badge {
padding: 0 8px;
}
.hb__badge svg {
width: 11px;
height: 11px;
}
.hb__btn {
padding: 4px 8px;
}
.hb__trust {
gap: 8px;
}
.hb__tile {
width: 24px;
height: 24px;
border-radius: 8px;
}
.hb__tile svg {
width: 13px;
height: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.hb__sheen, .hb__tile {
animation: none !important;
}
.hb__sheen {
opacity: 0 !important;
}
}(function () {
var root = document.querySelector('.hb');
var btns = [].slice.call(root.querySelectorAll('.hb__btn'));
function stopLoop() { root.classList.remove('is-demo'); }
// 버튼 쌍은 좌우 화살표로 오간다 (Home·End 는 양 끝).
// preventScroll 이 없으면 iframe 안 포커스가 부모 홈 페이지를 아래로 끌어당긴다
btns.forEach(function (btn, i) {
btn.addEventListener('keydown', function (e) {
var to = -1;
if (e.key === 'ArrowRight') { to = (i + 1) % btns.length; }
if (e.key === 'ArrowLeft') { to = (i - 1 + btns.length) % btns.length; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = btns.length - 1; }
if (to < 0) { return; }
e.preventDefault();
stopLoop();
btns[to].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroCenterBadge.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
export type TrustMark = {
/** 일반명사 라벨 — 실제 브랜드 이름을 쓰지 않는다 */
label: string;
icon: ReactNode;
};
export type HeroCenterBadgeProps = {
badge?: string;
title?: string;
lead?: string;
solid?: string;
line?: string;
trustLabel?: string;
marks?: TrustMark[];
sectionLabel?: string;
/** 빛줄기가 한 번 지나가고 신뢰 표시 넷이 차례로 들리는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 옐로 무대 위 글자·면의 색 — 잉크 고정을 권한다 (흰 글자는 1.44:1) */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CubeIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M128,129.09V232a8,8,0,0,1-3.84-1l-88-48.16a8,8,0,0,1-4.16-7V80.2a8,8,0,0,1,.7-3.27Z" opacity="0.2" />
<path d="M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32h0l80.34,44L128,120,47.66,76ZM40,90l80,43.78v85.79L40,175.82Zm96,129.57V133.82L216,90v85.78Z" />
</svg>
);
const CirclesIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M128,112a36,36,0,1,1,36-36A36,36,0,0,1,128,112Zm60,24a36,36,0,1,0,36,36A36,36,0,0,0,188,136ZM68,136a36,36,0,1,0,36,36A36,36,0,0,0,68,136Z" opacity="0.2" />
<path d="M172,76a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,76Zm-44,28a28,28,0,1,1,28-28A28,28,0,0,1,128,104Zm60,24a44,44,0,1,0,44,44A44.05,44.05,0,0,0,188,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,188,200ZM68,128a44,44,0,1,0,44,44A44.05,44.05,0,0,0,68,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,68,200Z" />
</svg>
);
const CrosshairIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M160,128a32,32,0,1,1-32-32A32,32,0,0,1,160,128Z" opacity="0.2" />
<path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z" />
</svg>
);
const TrendUpIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M232,56v64L168,56Z" opacity="0.2" />
<path d="M232,48H168a8,8,0,0,0-5.66,13.66L188.69,88,136,140.69l-34.34-34.35a8,8,0,0,0-11.32,0l-72,72a8,8,0,0,0,11.32,11.32L96,123.31l34.34,34.35a8,8,0,0,0,11.32,0L200,99.31l26.34,26.35A8,8,0,0,0,240,120V56A8,8,0,0,0,232,48Zm-8,52.69L187.31,64H224Z" />
</svg>
);
const DEFAULT_MARKS: TrustMark[] = [
{ label: "구성", icon: <CubeIcon /> },
{ label: "연동", icon: <CirclesIcon /> },
{ label: "정확도", icon: <CrosshairIcon /> },
{ label: "성장", icon: <TrendUpIcon /> },
];
export default function HeroCenterBadge({
badge = "이번 주 업데이트",
title = "새 기능이 먼저 도착합니다",
lead = "가입하면 공개 전에 미리 써 볼 수 있습니다.",
solid = "지금 신청하기",
line = "변경 내역 보기",
trustLabel = "신뢰 표시",
marks = DEFAULT_MARKS,
sectionLabel = "출시 안내 첫 화면",
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: HeroCenterBadgeProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const refs = useRef<Array<HTMLAnchorElement | null>>([]) as Box<Array<HTMLAnchorElement | null>>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const tone = [styles.mark1, styles.mark2, styles.mark3, styles.mark4];
/* 버튼 쌍은 좌우 화살표로 오간다. preventScroll 이 없으면 iframe 안 포커스가
부모 페이지를 아래로 끌어당긴다 */
const onKey = (e: KeyEvt, i: number): void => {
const list = refs.current;
if (!list) { return; }
let to = -1;
if (e.key === "ArrowRight") { to = (i + 1) % list.length; }
if (e.key === "ArrowLeft") { to = (i - 1 + list.length) % list.length; }
if (e.key === "Home") { to = 0; }
if (e.key === "End") { to = list.length - 1; }
if (to < 0) { return; }
e.preventDefault();
setLoop(false);
const next = list[to];
if (next) { next.focus({ preventScroll: true }); }
};
const labels = [solid, line];
return (
<section
className={loop ? `${styles.hb} ${styles.isDemo}` : styles.hb}
style={vars}
aria-label={sectionLabel}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.badge}>
<span className={styles.sheen} aria-hidden="true" />
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2" />
<path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z" />
</svg>
<span className={styles.badgeT}>{badge}</span>
</p>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead}>{lead}</p>
<div className={styles.actions}>
{labels.map((t: string, i: number) => (
<a
className={`${styles.btn} ${i === 0 ? styles.btnSolid : styles.btnLine}`}
href="#"
key={t}
ref={(el: HTMLAnchorElement | null) => { if (refs.current) { refs.current[i] = el; } }}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
{t}
</a>
))}
</div>
{/* 신뢰 표시 — 실제 로고가 아니라 둥근 사각형 안에 일반 아이콘 + 일반명사 라벨 */}
<ul className={styles.trust} aria-label={trustLabel}>
{marks.map((m: TrustMark, i: number) => (
<li className={`${styles.mark} ${tone[i % tone.length]}`} key={m.label}>
<span className={styles.tile}>{m.icon}</span>
<span className={styles.ml}>{m.label}</span>
</li>
))}
</ul>
</section>
);
}02 · Center-stack badge hero — Measured area 3.342% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
This is the shape for launch announcements and signup screens, and it kept moving in 23 of 24 frames at intensity 74.7 with 3.342% cumulative changed area.
.hb__sheen {
position: absolute;
left: 0;
top: 0;
width: 40%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, .62) 50%, rgba(255, 255, 255, 0) 100%);
transform: translateX(-160%);
}
@keyframes hbSheen {
0% { transform: translateX(-160%); }
70%, 100% { transform: translateX(320%); }
}
@keyframes hbLift {
0% { transform: translateY(0); }
16%, 46% { transform: translateY(-7px); }
88%, 100% { transform: translateY(0); }
}
03Word-by-word headline
What moves
The headline refuses to arrive at once: each word rises 115% out of its own overflow: hidden box on a .16s stagger, and once the third word lands, a yellow band .78em tall fills from the left behind the key word. The band rests at scaleX(1) in the static state, so a screenshot taken without the loop still shows the emphasis.

<main class="stage">
<section class="hk is-demo" aria-label="Brand first screen">
<!-- 낱말마다 span — 각 낱말이 제 상자 안에서 아래로부터 올라온다 -->
<h1 class="hk__title">
<span class="hk__word hk__word--1"><span class="hk__wi">Light</span></span>
<span class="hk__word hk__word--2"><span class="hk__wi">Fast</span></span>
<span class="hk__key">
<span class="hk__band" aria-hidden="true"></span>
<span class="hk__word hk__word--3"><span class="hk__wi">Sure</span></span>
</span>
</h1>
<p class="hk__lead">A brand line that ends in three words.</p>
<div class="hk__actions">
<a class="hk__btn" href="#">
<span>See the brand</span>
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg>
</a>
<button class="hk__again" type="button">
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true" focusable="false" fill="currentColor"><path d="M228.23,134.69,84.15,222.81A8,8,0,0,1,72,216.12V39.88a8,8,0,0,1,12.15-6.69l144.08,88.12A7.82,7.82,0,0,1,228.23,134.69Z" opacity="0.2"/><path d="M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z"/></svg>
<span>Play again</span>
</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 크림 위 제목 17.10:1 · 옐로 띠 위 글자 12.63:1
$hr-paper: $stage-paper; // 바이올렛 면 위 글자 6.14:1
$hr-cream: #fdf7ec; // 무대
$hr-stone: #56505e; // 크림 위 보조 글자 7.28:1
$hr-yellow: $stage-yellow; // 형광 띠 (그 위 잉크 글자 12.63:1)
$hr-violet: #9791c9; // 버튼 면 (흰 글자 6.14:1)
$duration: $dur-loop; // 낱말 셋이 올라오고 띠가 차오르는 한 바퀴
$easing: $ease-spring; // 낱말이 올라와 멎을 때의 감속
$color: $hr-yellow; // 이 항목의 강조색 — 형광 띠
@include stage($hr-cream);
.hk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
width: clamp(288px, 94vw, 432px);
text-align: center;
}
.hk__title {
margin: 0;
color: $hr-ink;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 28px);
line-height: 1.18;
letter-spacing: -.02em;
}
// 낱말 하나가 제 상자 안에서 올라온다 — 상자가 넘치는 부분을 잘라 줘야
// "아래에서 솟는" 것으로 보인다. 아랫여백은 받침이 잘리지 않게 4px 만 벌렸다 되접는다
.hk__word {
position: relative;
z-index: 1;
overflow: hidden;
display: inline-block;
padding-bottom: $sp-1;
margin-bottom: -$sp-1;
vertical-align: bottom;
}
.hk__wi { display: inline-block; }
// 가장 중요한 낱말 뒤에 깔리는 형광 띠. 기본값은 다 차 있는 상태 —
// .is-demo 를 뗀 정적 화면에서도 강조가 보여야 한다
.hk__key { position: relative; display: inline-block; }
.hk__band {
position: absolute;
left: -3px;
right: -3px;
bottom: 3px;
height: .78em;
border-radius: $r-xs;
background: $color;
transform: scaleX(1);
transform-origin: left center; // 왼쪽에서 오른쪽으로 차오른다
}
.hk__lead {
margin: 0;
color: $hr-stone;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
}
.hk__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.hk__btn {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-2 $sp-3;
border: 2px solid $hr-violet;
border-radius: $r-control;
background: $hr-violet;
color: $hr-paper;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hk__again {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border: 2px solid $hr-stone;
border-radius: $r-pill;
background: transparent;
color: $hr-stone;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.2;
cursor: pointer;
}
// 터치·키보드에서도 같은 표시
.hk__btn:focus-visible,
.hk__again:focus-visible {
outline: 2px solid $hr-violet;
outline-offset: 4px;
}
// ── 자동 재생 ─────────────────────────────────────────────
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다.
// 지연이 걸린 낱말·띠에는 animation-fill-mode: backwards 가 반드시 필요하다 —
// 없으면 0프레임에 완성 상태(다 올라온 낱말·다 찬 띠)가 한 번 번쩍인다
.hk.is-demo .hk__wi {
animation-name: hkRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hk.is-demo .hk__word--2 .hk__wi { animation-delay: .16s; }
.hk.is-demo .hk__word--3 .hk__wi { animation-delay: .32s; }
// 띠는 0.55s 에 시작해 낱말이 내려가기 전에 물러난다 (아래 @keyframes hkBand 주석과 같은 값)
.hk.is-demo .hk__band {
animation-name: hkBand;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: .55s;
animation-fill-mode: backwards;
}
// 올라오기(0~16%)가 내려가기(72~86%)보다 길어 프레임당 변화는 등장 쪽이 작지만,
// 시차가 0.16s 라 등장 구간에는 두 낱말이 겹쳐 움직인다 — 그래서 poster(앞 장과 가장 크게
// 달라진 장)는 낱말들이 올라와 서는 장면으로 뽑힌다. 실측으로 확인했다
@keyframes hkRise {
0% { transform: translateY(115%); }
16%, 72% { transform: translateY(0); }
86%, 100% { transform: translateY(115%); }
}
// 띠는 낱말이 다 선 뒤(0.55s)에 차올랐다가 낱말이 내려가기 전에 물러난다
@keyframes hkBand {
0% { transform: scaleX(0); }
12%, 50% { transform: scaleX(1); }
60%, 100% { transform: scaleX(0); }
}
// ── "다시 보기" 가 부르는 일회성 등장 ─────────────────────
.hk.is-replay .hk__wi {
animation-name: hkEnter;
animation-duration: $dur-enter;
animation-timing-function: $easing;
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.hk.is-replay .hk__word--2 .hk__wi { animation-delay: .16s; }
.hk.is-replay .hk__word--3 .hk__wi { animation-delay: .32s; }
.hk.is-replay .hk__band {
animation-name: hkFill;
animation-duration: $dur-enter;
animation-timing-function: $easing;
animation-iteration-count: 1;
animation-delay: .55s;
animation-fill-mode: backwards;
}
@keyframes hkEnter {
from { transform: translateY(115%); }
to { transform: translateY(0); }
}
@keyframes hkFill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
// 좁은 화면 — 한 줄을 유지한 채 글자·간격만 줄인다. 여기서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.hk { gap: $sp-2; width: 100%; }
.hk__band { bottom: 2px; }
.hk__actions { gap: $sp-2; }
.hk__btn { padding: $sp-1 $sp-2; }
.hk__again { padding: 0 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// .hk.is-demo .hk__wi (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 낱말과 형광 띠는 그대로 보인다
.hk__wi, .hk__band { animation: none !important; transform: none !important; }
.hk__band { transform: scaleX(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fdf7ec;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hk {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: clamp(288px, 94vw, 432px);
text-align: center;
}
.hk__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 28px);
line-height: 1.18;
letter-spacing: -0.02em;
}
.hk__word {
position: relative;
z-index: 1;
overflow: hidden;
display: inline-block;
padding-bottom: 4px;
margin-bottom: -4px;
vertical-align: bottom;
}
.hk__wi {
display: inline-block;
}
.hk__key {
position: relative;
display: inline-block;
}
.hk__band {
position: absolute;
left: -3px;
right: -3px;
bottom: 3px;
height: 0.78em;
border-radius: 4px;
background: #ffd23f;
transform: scaleX(1);
transform-origin: left center;
}
.hk__lead {
margin: 0;
color: #56505e;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1.35;
}
.hk__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
}
.hk__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 2px solid #9791c9;
border-radius: 12px;
background: #9791c9;
color: #ffffff;
font-weight: 700;
font-size: clamp(10px, 2.2vw, 12px);
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.hk__again {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 8px;
border: 2px solid #56505e;
border-radius: 999px;
background: transparent;
color: #56505e;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2vw, 11px);
line-height: 1.2;
cursor: pointer;
}
.hk__btn:focus-visible,
.hk__again:focus-visible {
outline: 2px solid #9791c9;
outline-offset: 4px;
}
.hk.is-demo .hk__wi {
animation-name: hkRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hk.is-demo .hk__word--2 .hk__wi {
animation-delay: 0.16s;
}
.hk.is-demo .hk__word--3 .hk__wi {
animation-delay: 0.32s;
}
.hk.is-demo .hk__band {
animation-name: hkBand;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: 0.55s;
animation-fill-mode: backwards;
}
@keyframes hkRise {
0% {
transform: translateY(115%);
}
16%, 72% {
transform: translateY(0);
}
86%, 100% {
transform: translateY(115%);
}
}
@keyframes hkBand {
0% {
transform: scaleX(0);
}
12%, 50% {
transform: scaleX(1);
}
60%, 100% {
transform: scaleX(0);
}
}
.hk.is-replay .hk__wi {
animation-name: hkEnter;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.hk.is-replay .hk__word--2 .hk__wi {
animation-delay: 0.16s;
}
.hk.is-replay .hk__word--3 .hk__wi {
animation-delay: 0.32s;
}
.hk.is-replay .hk__band {
animation-name: hkFill;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-delay: 0.55s;
animation-fill-mode: backwards;
}
@keyframes hkEnter {
from {
transform: translateY(115%);
}
to {
transform: translateY(0);
}
}
@keyframes hkFill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hk {
gap: 8px;
width: 100%;
}
.hk__band {
bottom: 2px;
}
.hk__actions {
gap: 8px;
}
.hk__btn {
padding: 4px 8px;
}
.hk__again {
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hk__wi, .hk__band {
animation: none !important;
transform: none !important;
}
.hk__band {
transform: scaleX(1) !important;
}
}(function () {
var root = document.querySelector('.hk');
var again = root.querySelector('.hk__again');
var band = root.querySelector('.hk__band');
function stopLoop() { root.classList.remove('is-demo'); }
// "Play again" 는 등장만 한 번 되풀이한다.
// 클래스를 뗐다 붙이는 사이에 리플로를 한 번 일으켜야 애니메이션이 다시 걸린다
function replay() {
root.classList.remove('is-replay');
void root.offsetWidth;
root.classList.add('is-replay');
}
again.addEventListener('click', function () {
stopLoop();
replay();
});
// 형광 띠가 가장 늦게 끝나므로 그 끝에서 일회성 클래스를 뗀다
band.addEventListener('animationend', function () {
root.classList.remove('is-replay');
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroKineticHeadline.module.scss";
export type HeroKineticHeadlineProps = {
/** 낱말 단위로 올라올 헤드라인. 마지막 낱말 뒤에 형광 띠가 깔린다 */
words?: string[];
lead?: string;
cta?: string;
again?: string;
sectionLabel?: string;
/** 낱말 셋이 올라오고 띠가 차오르는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 형광 띠 색 — 잉크 글자와 12.63:1 인 옐로를 권한다 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ArrowRightIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M216,128l-72,72V56Z" opacity="0.2" />
<path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z" />
</svg>
);
const PlayIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M228.23,134.69,84.15,222.81A8,8,0,0,1,72,216.12V39.88a8,8,0,0,1,12.15-6.69l144.08,88.12A7.82,7.82,0,0,1,228.23,134.69Z" opacity="0.2" />
<path d="M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z" />
</svg>
);
export default function HeroKineticHeadline({
words = ["가볍게", "빠르게", "확실하게"],
lead = "세 낱말로 끝나는 브랜드 한 줄.",
cta = "브랜드 보러 가기",
again = "다시 보기",
sectionLabel = "브랜드 첫 화면",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: HeroKineticHeadlineProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [replay, setReplay] = useState<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const last = words.length - 1;
const tone = [styles.word1, styles.word2, styles.word3];
/* "다시 보기" 는 등장만 한 번 되풀이한다 — key 를 바꿔 제목을 새로 마운트해야
끝난 일회성 애니메이션이 다시 걸린다 */
const onAgain = (): void => {
setLoop(false);
setReplay((n: number) => n + 1);
};
const cls = [styles.hk, loop ? styles.isDemo : "", replay > 0 ? styles.isReplay : ""].filter(Boolean).join(" ");
const word = (w: string, i: number): ReactNode => (
<span className={`${styles.word} ${tone[i % tone.length]}`} key={`${w}-${i}`}>
<span className={styles.wi}>{w}</span>
</span>
);
return (
<section
className={cls}
style={vars}
aria-label={sectionLabel}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
{/* 낱말마다 span — 각 낱말이 제 상자 안에서 아래로부터 올라온다 */}
<h1 className={styles.title} key={replay}>
{words.slice(0, last).map(word)}
<span className={styles.key}>
<span className={styles.band} aria-hidden="true" />
{word(words[last] || "", last)}
</span>
</h1>
<p className={styles.lead}>{lead}</p>
<div className={styles.actions}>
<a className={styles.btn} href="#">
<span>{cta}</span>
<ArrowRightIcon />
</a>
<button className={styles.again} type="button" onClick={onAgain}>
<PlayIcon />
<span>{again}</span>
</button>
</div>
</section>
);
}03 · Word-by-word headline — Measured area 3.274% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Choose it when one sentence carries the whole brand screen; it is among the smallest movers by area at 3.274%, yet its 16 moving frames change hard enough to average intensity 131.3.
.hk__word {
position: relative;
z-index: 1;
overflow: hidden;
display: inline-block;
padding-bottom: $sp-1;
margin-bottom: -$sp-1;
vertical-align: bottom;
}
.hk__wi { display: inline-block; }
.hk__key { position: relative; display: inline-block; }
.hk__band {
position: absolute;
left: -3px;
right: -3px;
bottom: 3px;
height: .78em;
border-radius: $r-xs;
background: $color;
transform: scaleX(1);
transform-origin: left center;
}
04Gradient glass hero
What you see
Three radial-gradient blobs drift over an ink floor with no photo and no blur filter, since a radial gradient already arrives with soft edges, while a glass card floats above them on backdrop-filter: blur(12px) saturate(140%) and a diagonal beam sweeps its surface once, held inside the card's own corner radius by a clip-path: inset(). It is the widest-moving of the nine at 34.261% cumulative changed area across 19 moving frames, and at the same time the calmest pixel for pixel at intensity 32.2.

<main class="stage">
<section class="hg is-demo">
<!-- 배경: 사진이 아니라 radial-gradient 색 덩어리 세 개. 서로 다른 위상으로 느리게 흐른다 -->
<div class="hg__bg" aria-hidden="true">
<span class="hg__blob hg__blob--a"></span>
<span class="hg__blob hg__blob--b"></span>
<span class="hg__blob hg__blob--c"></span>
</div>
<div class="hg__card">
<!-- 빛줄기는 clip-path 로 카드 모양 안에 갇힌다 -->
<span class="hg__sheen" aria-hidden="true"><i class="hg__beam"></i></span>
<h1 class="hg__title">Striking first screens, no photos</h1>
<p class="hg__sub">Three color blobs and one glass pane replace the background image.</p>
<a class="hg__cta" href="#">Start for free
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg>
</a>
</div>
<button class="hg__switch" type="button" aria-pressed="false">Pause the light</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 무대(잉크)
$hr-paper: $stage-paper; // 제목·버튼 면
$hr-mist: #b6b0c0; // 잉크 위 보조 글자 8.65:1
$hr-violet: #9791c9; // 색 덩어리 1
$hr-orange: $stage-orange; // 색 덩어리 2
$hr-grass: #479074; // 색 덩어리 3
$duration: $dur-loop; // 덩어리 한 바퀴 · 빛줄기 한 번
$easing: $ease-spring; // 덩어리가 흐르는 결
$color: $hr-violet; // 이 항목의 강조색
@include stage($hr-ink);
// 히어로 — 사진 한 장 없이 잉크 바닥 + 색 덩어리로 배경을 만든다
.hg {
position: relative;
box-sizing: border-box;
overflow: hidden;
display: grid;
place-items: center;
width: clamp(272px, 94vw, 424px);
padding: clamp(16px, 4vw, 24px);
border-radius: $r-card;
background: $hr-ink;
}
.hg__bg {
position: absolute;
inset: 0;
}
// 덩어리는 radial-gradient — 가장자리가 이미 부드러워 blur 필터가 필요 없다
.hg__blob {
position: absolute;
width: 240px;
height: 240px;
border-radius: 50%;
}
.hg__blob--a {
left: -64px;
top: -104px;
background: radial-gradient(circle at 50% 50%, $color, rgba($color, 0) 70%);
}
// 밝은 덩어리 둘은 글자가 앉는 왼쪽 위를 피해 아래로 내린다 (미스트 글자의 대비를 지킨다).
// b 는 오른쪽 아래 모서리(중심 가로 97%), c 는 바닥의 오른쪽 절반이다 — `left: 44%` 는 덩어리의
// **왼쪽 모서리**라 424px 무대에서 187~427px 을 덮고 중심은 72% 다. 가운데가 아니다
.hg__blob--b {
right: -108px;
bottom: -136px;
background: radial-gradient(circle at 50% 50%, $hr-orange, rgba($hr-orange, 0) 70%);
}
.hg__blob--c {
left: 44%;
bottom: -160px;
background: radial-gradient(circle at 50% 50%, $hr-grass, rgba($hr-grass, 0) 70%);
}
// 유리 카드 — 뒤의 덩어리를 backdrop-filter 로 흐려 받는다
.hg__card {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: clamp(12px, 3.2vw, 20px);
border: 1px solid rgba(255, 255, 255, .28);
border-radius: $r-card;
background: rgba(255, 255, 255, .10);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
}
// 빛줄기 — clip-path 로 카드 모양(같은 반경) 안에 가둔다
.hg__sheen {
position: absolute;
inset: 0;
clip-path: inset(0 round #{$r-card});
pointer-events: none;
}
.hg__beam {
--sweep: 440px;
position: absolute;
left: 0;
top: -60%;
bottom: -60%;
width: 72px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .55) 50%, rgba(255, 255, 255, 0));
transform: rotate(18deg) translateX(-120px);
}
.hg__title {
margin: 0;
color: $hr-paper; // 흰 글자 on 잉크 18.24:1
font-weight: 800; font-size: clamp(15px, 3.8vw, 22px); line-height: 1.18;
letter-spacing: -.015em;
word-break: keep-all; // 한국어는 낱말 단위로 줄을 바꾼다
}
.hg__sub {
margin: $sp-1 0 0;
color: $hr-mist; // 미스트 on 잉크 8.65:1
font-weight: 500; font-size: clamp(10px, 2.5vw, 13px); line-height: 1.45;
}
.hg__cta {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin-top: clamp(8px, 2.4vw, 12px);
padding: clamp(4px, 1.6vw, 8px) clamp(8px, 3vw, 16px);
border-radius: $r-pill;
background: $hr-paper;
color: $hr-ink; // 잉크 글자 on 흰 면 18.24:1
font-weight: 700; font-size: clamp(10px, 2.4vw, 13px); line-height: 1;
text-decoration: none;
}
.hg__cta:focus-visible {
outline: 2px solid $hr-paper;
outline-offset: 3px;
}
// 빛의 흐름만 멈추는 작은 스위치
.hg__switch {
position: absolute;
z-index: 2;
top: 6px;
right: 6px;
padding: $sp-1 $sp-2;
border: 1px solid rgba(255, 255, 255, .32);
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
color: $hr-paper;
font-weight: 600; font-size: 10px; line-height: 1.2;
cursor: pointer;
}
.hg__switch[aria-pressed="true"] {
background: rgba(255, 255, 255, .26);
}
.hg__switch:focus-visible {
outline: 2px solid $hr-paper;
outline-offset: 2px;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 덩어리 셋은 같은 길이(캡처 창 2초의 약수여야 한다)의 다른 경로·다른 위상으로 흐른다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.hg.is-demo .hg__blob {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hg.is-demo .hg__blob--a { animation-name: hgDriftA; }
.hg.is-demo .hg__blob--b { animation-name: hgDriftB; animation-delay: -.6667s; }
.hg.is-demo .hg__blob--c { animation-name: hgDriftC; animation-delay: -1.3333s; }
.hg.is-demo .hg__beam {
animation-name: hgSheen;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
// 스위치를 누르면 있던 자리에 그대로 선다 (같은 특이도 · 뒤에 오므로 이긴다)
.hg.is-paused .hg__blob,
.hg.is-paused .hg__beam { animation-play-state: paused; }
@keyframes hgDriftA {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(34px, 22px); }
}
@keyframes hgDriftB {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-30px, -20px); }
}
@keyframes hgDriftC {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(24px, -28px); }
}
// 빛줄기는 카드 왼쪽 밖에서 들어와 오른쪽 밖으로 나간다 — 되감기는 clip-path 밖에서 일어나 보이지 않는다
@keyframes hgSheen {
0%, 6% { transform: rotate(18deg) translateX(-120px); }
56%, 100% { transform: rotate(18deg) translateX(var(--sweep)); }
}
// 좁은 화면 — 세로로 쌓지 않고 글자·여백만 줄인다 (이 안에서는 간격을 올리지 않는다)
@media (max-width: 340px), (max-height: 220px) {
.hg { width: 100%; padding: $sp-3; }
.hg__card { padding: $sp-2; }
.hg__blob { width: 180px; height: 180px; }
.hg__beam { --sweep: 300px; width: 56px; }
.hg__switch { padding: 0 $sp-1; font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
// .hg.is-demo .hg__blob (0,3,0) 를 이기려면 !important 가 필요하다 — 카드·글자는 그대로 남는다
.hg__blob, .hg__beam { animation: 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);
}
.hg {
position: relative;
box-sizing: border-box;
overflow: hidden;
display: grid;
place-items: center;
width: clamp(272px, 94vw, 424px);
padding: clamp(16px, 4vw, 24px);
border-radius: 16px;
background: #17141a;
}
.hg__bg {
position: absolute;
inset: 0;
}
.hg__blob {
position: absolute;
width: 240px;
height: 240px;
border-radius: 50%;
}
.hg__blob--a {
left: -64px;
top: -104px;
background: radial-gradient(circle at 50% 50%, #9791c9, rgba(151, 145, 201, 0) 70%);
}
.hg__blob--b {
right: -108px;
bottom: -136px;
background: radial-gradient(circle at 50% 50%, #ff4d1f, rgba(255, 77, 31, 0) 70%);
}
.hg__blob--c {
left: 44%;
bottom: -160px;
background: radial-gradient(circle at 50% 50%, #479074, rgba(71, 144, 116, 0) 70%);
}
.hg__card {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: clamp(12px, 3.2vw, 20px);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
}
.hg__sheen {
position: absolute;
inset: 0;
clip-path: inset(0 round 16px);
pointer-events: none;
}
.hg__beam {
--sweep: 440px;
position: absolute;
left: 0;
top: -60%;
bottom: -60%;
width: 72px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.55) 50%, rgba(255, 255, 255, 0));
transform: rotate(18deg) translateX(-120px);
}
.hg__title {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(15px, 3.8vw, 22px);
line-height: 1.18;
letter-spacing: -0.015em;
word-break: keep-all;
}
.hg__sub {
margin: 4px 0 0;
color: #b6b0c0;
font-weight: 500;
font-size: clamp(10px, 2.5vw, 13px);
line-height: 1.45;
}
.hg__cta {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: clamp(8px, 2.4vw, 12px);
padding: clamp(4px, 1.6vw, 8px) clamp(8px, 3vw, 16px);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: clamp(10px, 2.4vw, 13px);
line-height: 1;
text-decoration: none;
}
.hg__cta:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
.hg__switch {
position: absolute;
z-index: 2;
top: 6px;
right: 6px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font-weight: 600;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.hg__switch[aria-pressed=true] {
background: rgba(255, 255, 255, 0.26);
}
.hg__switch:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.hg.is-demo .hg__blob {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hg.is-demo .hg__blob--a {
animation-name: hgDriftA;
}
.hg.is-demo .hg__blob--b {
animation-name: hgDriftB;
animation-delay: -0.6667s;
}
.hg.is-demo .hg__blob--c {
animation-name: hgDriftC;
animation-delay: -1.3333s;
}
.hg.is-demo .hg__beam {
animation-name: hgSheen;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.hg.is-paused .hg__blob,
.hg.is-paused .hg__beam {
animation-play-state: paused;
}
@keyframes hgDriftA {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(34px, 22px);
}
}
@keyframes hgDriftB {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(-30px, -20px);
}
}
@keyframes hgDriftC {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(24px, -28px);
}
}
@keyframes hgSheen {
0%, 6% {
transform: rotate(18deg) translateX(-120px);
}
56%, 100% {
transform: rotate(18deg) translateX(var(--sweep));
}
}
@media (max-width: 340px), (max-height: 220px) {
.hg {
width: 100%;
padding: 12px;
}
.hg__card {
padding: 8px;
}
.hg__blob {
width: 180px;
height: 180px;
}
.hg__beam {
--sweep: 300px;
width: 56px;
}
.hg__switch {
padding: 0 4px;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.hg__blob, .hg__beam {
animation: none !important;
}
}(function () {
var root = document.querySelector('.hg');
var card = root.querySelector('.hg__card');
var sw = root.querySelector('.hg__switch');
// 격자 미리보기용 자동 루프 — 카드에 손이 닿으면 뗀다 (스위치는 예외라 눌러서 다시 켤 수 있다)
function stopLoop() { root.classList.remove('is-demo'); }
sw.addEventListener('click', function () {
var paused = sw.getAttribute('aria-pressed') === 'true';
sw.setAttribute('aria-pressed', paused ? 'false' : 'true');
// animation-play-state 만 바꾼다 — 덩어리도 빛줄기도 있던 자리에 그대로 선다
root.classList.toggle('is-paused', !paused);
if (paused) { root.classList.add('is-demo'); }
});
card.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', function (e) {
if (e.target !== sw) { stopLoop(); }
});
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroGradientGlass.module.scss";
/* ambient 에 FocusEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type FocusEvt = { target: unknown; currentTarget: unknown };
export type HeroGradientGlassProps = {
title?: string;
sub?: string;
cta?: string;
/** 빛의 흐름을 멈추는 스위치의 이름 */
switchLabel?: string;
/** 덩어리 한 바퀴 · 빛줄기 한 번(ms) */
duration?: number;
easing?: string;
/** 첫 번째 색 덩어리이자 이 항목의 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ArrowRight = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M216,128l-72,72V56Z" opacity="0.2" />
<path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z" />
</svg>
);
export default function HeroGradientGlass({
title = "사진 없이도 첫 화면이 빛난다",
sub = "색 덩어리 셋과 유리 한 장이면 배경 이미지가 필요 없습니다.",
cta = "무료로 시작하기",
switchLabel = "빛 흐름 멈춤",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#5b4bd6",
}: HeroGradientGlassProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [paused, setPaused] = useState<boolean>(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 스위치는 animation-play-state 만 바꾼다 — 다시 누르면 미리보기 루프도 함께 돌아온다 */
const toggle = (): void => {
setPaused(!paused);
if (paused) { setLoop(true); }
};
const cls = [styles.hg, loop ? styles.isDemo : "", paused ? styles.isPaused : ""].join(" ").trim();
return (
<section
className={cls}
style={vars}
onFocus={(e: FocusEvt) => { if (e.target !== e.currentTarget) { setLoop(false); } }}
>
{/* 배경: 사진이 아니라 radial-gradient 색 덩어리 세 개 */}
<div className={styles.bg} aria-hidden="true">
<span className={`${styles.blob} ${styles.blobA}`} />
<span className={`${styles.blob} ${styles.blobB}`} />
<span className={`${styles.blob} ${styles.blobC}`} />
</div>
<div className={styles.card} onPointerEnter={() => setLoop(false)}>
{/* 빛줄기는 clip-path 로 카드 모양 안에 갇힌다 */}
<span className={styles.sheen} aria-hidden="true"><i className={styles.beam} /></span>
<h1 className={styles.title}>{title}</h1>
<p className={styles.sub}>{sub}</p>
<a className={styles.cta} href="#">
{cta}
<ArrowRight />
</a>
</div>
<button className={styles.switch} type="button" aria-pressed={paused} onClick={toggle}>
{switchLabel}
</button>
</section>
);
}04 · Gradient glass hero — Measured area 34.29% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Paste it onto a service screen that has no image assets at all.
.hg__card {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: clamp(12px, 3.2vw, 20px);
border: 1px solid rgba(255, 255, 255, .28);
border-radius: $r-card;
background: rgba(255, 255, 255, .10);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
}
.hg__sheen {
position: absolute;
inset: 0;
clip-path: inset(0 round #{$r-card});
pointer-events: none;
}
05Product zoom hotspot hero
What happens on screen
The product art zooms slowly to 1.12 with its origin at 30% 34% and eases back to 1 by 88%, two hotspots swell to 1.28 and burst rings out to 2.8 times their size, and where each burst lands a caption note cuts in on steps(1, end) rather than fading, so no half-transparent ghost of a caption ever exists. The hotspots are real buttons that open their notes on click, which means the same captions work with the autoplay switched off.

<main class="stage">
<section class="hz is-demo">
<div class="hz__copy">
<h1 class="hz__title">Point at the feature, then show it</h1>
<p class="hz__sub">Tap a dot and a caption pops up right there.</p>
</div>
<div class="hz__frame">
<!-- 제품 그림은 사진이 아니라 도형 조합 — transform-origin 을 호출점 쪽에 두고 천천히 확대된다 -->
<div class="hz__art" aria-hidden="true">
<span class="hz__screen">
<i class="hz__bar hz__bar--head"></i>
<i class="hz__bar hz__bar--a"></i>
<span class="hz__row">
<i class="hz__tile hz__tile--v"></i>
<i class="hz__tile hz__tile--g"></i>
</span>
</span>
</div>
<div class="hz__spot hz__spot--1">
<button class="hz__dot" type="button" aria-expanded="false" aria-controls="hz-note-1" aria-label="Open the metrics caption">
<span class="hz__ring" aria-hidden="true"></span>
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true" focusable="false" fill="currentColor"><path d="M160,128a32,32,0,1,1-32-32A32,32,0,0,1,160,128Z" opacity="0.2"/><path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z"/></svg>
</button>
<p class="hz__note" id="hz-note-1">Metrics at a glance</p>
</div>
<div class="hz__spot hz__spot--2">
<button class="hz__dot" type="button" aria-expanded="false" aria-controls="hz-note-2" aria-label="Open the alerts caption">
<span class="hz__ring" aria-hidden="true"></span>
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true" focusable="false" fill="currentColor"><path d="M160,128a32,32,0,1,1-32-32A32,32,0,0,1,160,128Z" opacity="0.2"/><path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z"/></svg>
</button>
<p class="hz__note" id="hz-note-2">Only the alerts you pick</p>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 오렌지 무대 위 글자는 전부 잉크 (5.50:1)
$hr-paper: $stage-paper; // 액자 · 쪽지 글자
$hr-cream: #fdf7ec; // 제품 화면 바닥
$hr-orange: $stage-orange; // 무대
$hr-violet: #9791c9; // 제품 화면 타일 1
$hr-grass: #479074; // 제품 화면 타일 2
$duration: $dur-loop; // 확대 한 바퀴 · 호출점 둘이 차례로 터지는 한 바퀴
$easing: $ease-out; // 확대가 붙었다 풀리는 결
$color: $hr-violet; // 이 항목의 강조색
@include stage($hr-orange);
// 왼쪽 글 · 오른쪽 액자. 좁아져도 세로로 쌓지 않는다 (쌓으면 오히려 세로로 넘친다)
.hz {
box-sizing: border-box;
display: flex;
align-items: center;
gap: clamp(8px, 3vw, 16px);
width: clamp(272px, 94vw, 432px);
// 쪽지가 absolute 라 부모 높이에 안 들어간다 — scrollHeight 만큼 미리 자리를 확보한다
padding-bottom: $sp-2;
}
.hz__copy {
flex: 1 1 38%;
min-width: 0;
}
.hz__title {
margin: 0;
color: $hr-ink; // 잉크 on 오렌지 5.50:1
font-weight: 800; font-size: clamp(12px, 3.2vw, 18px); line-height: 1.2;
letter-spacing: -.01em;
word-break: keep-all; // 한국어는 낱말 단위로 줄을 바꾼다
}
.hz__sub {
margin: $sp-1 0 0;
color: $hr-ink;
font-weight: 500; font-size: clamp(9px, 2.2vw, 12px); line-height: 1.4;
}
// 흰 액자 — 확대된 그림은 여기서 잘린다
.hz__frame {
position: relative;
flex: 1 1 62%;
min-width: 0;
box-sizing: border-box;
padding: $sp-3;
border-radius: $r-card;
background: $hr-paper;
overflow: hidden;
}
// 제품 그림 — 도형 조합. 원점을 첫 호출점 쪽에 둔다
.hz__art {
transform-origin: 30% 34%; // 규칙8: 호출점 기준 카메라 확대
transform: scale(1);
}
.hz__screen {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
height: clamp(72px, 24vw, 104px);
padding: $sp-2;
border-radius: $r-sm;
background: $hr-cream;
}
.hz__bar {
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
}
.hz__bar--head { width: 42%; height: 8px; }
.hz__bar--a { width: 68%; height: 6px; background: rgba(23, 20, 26, .32); }
.hz__row {
display: flex;
gap: $sp-1;
flex: 1 1 auto;
min-height: 0;
}
.hz__tile {
flex: 1 1 0;
border-radius: $r-sm;
}
.hz__tile--v { background: $color; }
.hz__tile--g { flex: 0 0 32%; background: $hr-grass; }
// 호출점 — 크기 0 의 닻. 점과 쪽지가 여기에 매달린다
.hz__spot {
position: absolute;
width: 0;
height: 0;
}
.hz__spot--1 { left: 26%; top: 32%; }
.hz__spot--2 { left: 46%; top: 66%; }
.hz__dot {
position: absolute;
left: -9px;
top: -9px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: $hr-ink;
color: $hr-paper; // 흰 아이콘 on 잉크 18.24:1
cursor: pointer;
}
.hz__dot:focus-visible {
outline: 2px solid $hr-ink;
outline-offset: 2px;
}
// 부풀었다 터지는 고리
.hz__ring {
position: absolute;
inset: 0;
border: 2px solid $hr-ink;
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
// 쪽지 — 기본은 닫힘. 누르면 열리고, 미리보기에서는 컷으로 나타난다
.hz__note {
position: absolute;
left: 14px;
top: -8px;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $hr-ink;
color: $hr-paper;
font-weight: 600; font-size: clamp(8px, 2vw, 11px); line-height: 1.2;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity $dur-quick $ease-out;
}
.hz__spot.is-open .hz__note {
opacity: 1;
visibility: visible;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 그림은 루프 내내 천천히 커졌다 제자리로 돌아오고(90% 안), 그 위에서 호출점 둘이 차례로 터진다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.hz.is-demo .hz__art {
animation-name: hzZoom;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.hz.is-demo .hz__dot {
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.hz.is-demo .hz__spot--1 .hz__dot { animation-name: hzPulse1; }
.hz.is-demo .hz__spot--2 .hz__dot { animation-name: hzPulse2; }
.hz.is-demo .hz__ring {
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.hz.is-demo .hz__spot--1 .hz__ring { animation-name: hzBurst1; }
.hz.is-demo .hz__spot--2 .hz__ring { animation-name: hzBurst2; }
// 쪽지는 반투명 유령을 만들지 않는다 — steps(1, end) 로 한 프레임에 컷
.hz.is-demo .hz__note {
visibility: visible;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
transition: none;
}
.hz.is-demo .hz__spot--1 .hz__note { animation-name: hzNote1; }
.hz.is-demo .hz__spot--2 .hz__note { animation-name: hzNote2; }
@keyframes hzZoom {
0% { transform: scale(1); }
52% { transform: scale(1.12); }
88%, 100% { transform: scale(1); }
}
// 점은 작은 transform 으로 계속 살아 있다 — 컷만 있으면 움직인 프레임이 모자란다
@keyframes hzPulse1 {
0% { transform: scale(1); }
8% { transform: scale(1.28); }
22%, 100% { transform: scale(1); }
}
@keyframes hzPulse2 {
0%, 40% { transform: scale(1); }
46% { transform: scale(1.28); }
58%, 100% { transform: scale(1); }
}
@keyframes hzBurst1 {
0%, 4% { transform: scale(1); opacity: .9; }
26% { transform: scale(2.8); opacity: 0; }
100% { transform: scale(2.8); opacity: 0; }
}
@keyframes hzBurst2 {
0%, 40% { transform: scale(1); opacity: 0; }
44% { transform: scale(1); opacity: .9; }
62%, 100% { transform: scale(2.8); opacity: 0; }
}
// 컷 — 터진 자리에 한 프레임에 나타나고, 루프 복귀는 90% 안에 끝낸다
@keyframes hzNote1 {
0% { opacity: 0; }
26% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes hzNote2 {
0% { opacity: 0; }
62% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
// 좁은 화면 — 글자·여백만 줄이고 두 칸을 유지한다 (이 안에서는 간격을 올리지 않는다)
@media (max-width: 340px), (max-height: 220px) {
.hz { gap: $sp-2; width: 100%; padding-bottom: $sp-1; }
.hz__frame { padding: $sp-2; }
.hz__screen { padding: $sp-1; }
// 쪽지가 액자 밖으로 잘리지 않게 호출점을 왼쪽으로 당긴다 (실측: 320px 에서 오른쪽 끝이 잘렸다)
.hz__spot--1 { left: 18%; top: 30%; }
.hz__spot--2 { left: 24%; top: 66%; }
.hz__note { left: 12px; padding: 0 $sp-1; }
.hz__dot { width: 16px; height: 16px; left: -8px; top: -8px; }
}
@media (prefers-reduced-motion: reduce) {
// .hz.is-demo .hz__art (0,3,0) 를 이기려면 !important 가 필요하다 — 쪽지는 열린 상태를 그대로 남긴다
.hz__art, .hz__dot, .hz__ring, .hz__note { animation: none !important; transition: none !important; }
.hz.is-demo .hz__note { opacity: 1 !important; }
}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);
}
.hz {
box-sizing: border-box;
display: flex;
align-items: center;
gap: clamp(8px, 3vw, 16px);
width: clamp(272px, 94vw, 432px);
padding-bottom: 8px;
}
.hz__copy {
flex: 1 1 38%;
min-width: 0;
}
.hz__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 18px);
line-height: 1.2;
letter-spacing: -0.01em;
word-break: keep-all;
}
.hz__sub {
margin: 4px 0 0;
color: #17141a;
font-weight: 500;
font-size: clamp(9px, 2.2vw, 12px);
line-height: 1.4;
}
.hz__frame {
position: relative;
flex: 1 1 62%;
min-width: 0;
box-sizing: border-box;
padding: 12px;
border-radius: 16px;
background: #ffffff;
overflow: hidden;
}
.hz__art {
transform-origin: 30% 34%;
transform: scale(1);
}
.hz__screen {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
height: clamp(72px, 24vw, 104px);
padding: 8px;
border-radius: 8px;
background: #fdf7ec;
}
.hz__bar {
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
}
.hz__bar--head {
width: 42%;
height: 8px;
}
.hz__bar--a {
width: 68%;
height: 6px;
background: rgba(23, 20, 26, 0.32);
}
.hz__row {
display: flex;
gap: 4px;
flex: 1 1 auto;
min-height: 0;
}
.hz__tile {
flex: 1 1 0;
border-radius: 8px;
}
.hz__tile--v {
background: #9791c9;
}
.hz__tile--g {
flex: 0 0 32%;
background: #479074;
}
.hz__spot {
position: absolute;
width: 0;
height: 0;
}
.hz__spot--1 {
left: 26%;
top: 32%;
}
.hz__spot--2 {
left: 46%;
top: 66%;
}
.hz__dot {
position: absolute;
left: -9px;
top: -9px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: #17141a;
color: #ffffff;
cursor: pointer;
}
.hz__dot:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.hz__ring {
position: absolute;
inset: 0;
border: 2px solid #17141a;
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
.hz__note {
position: absolute;
left: 14px;
top: -8px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-weight: 600;
font-size: clamp(8px, 2vw, 11px);
line-height: 1.2;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 220ms ease-out;
}
.hz__spot.is-open .hz__note {
opacity: 1;
visibility: visible;
}
.hz.is-demo .hz__art {
animation-name: hzZoom;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.hz.is-demo .hz__dot {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.hz.is-demo .hz__spot--1 .hz__dot {
animation-name: hzPulse1;
}
.hz.is-demo .hz__spot--2 .hz__dot {
animation-name: hzPulse2;
}
.hz.is-demo .hz__ring {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.hz.is-demo .hz__spot--1 .hz__ring {
animation-name: hzBurst1;
}
.hz.is-demo .hz__spot--2 .hz__ring {
animation-name: hzBurst2;
}
.hz.is-demo .hz__note {
visibility: visible;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
transition: none;
}
.hz.is-demo .hz__spot--1 .hz__note {
animation-name: hzNote1;
}
.hz.is-demo .hz__spot--2 .hz__note {
animation-name: hzNote2;
}
@keyframes hzZoom {
0% {
transform: scale(1);
}
52% {
transform: scale(1.12);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes hzPulse1 {
0% {
transform: scale(1);
}
8% {
transform: scale(1.28);
}
22%, 100% {
transform: scale(1);
}
}
@keyframes hzPulse2 {
0%, 40% {
transform: scale(1);
}
46% {
transform: scale(1.28);
}
58%, 100% {
transform: scale(1);
}
}
@keyframes hzBurst1 {
0%, 4% {
transform: scale(1);
opacity: 0.9;
}
26% {
transform: scale(2.8);
opacity: 0;
}
100% {
transform: scale(2.8);
opacity: 0;
}
}
@keyframes hzBurst2 {
0%, 40% {
transform: scale(1);
opacity: 0;
}
44% {
transform: scale(1);
opacity: 0.9;
}
62%, 100% {
transform: scale(2.8);
opacity: 0;
}
}
@keyframes hzNote1 {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes hzNote2 {
0% {
opacity: 0;
}
62% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.hz {
gap: 8px;
width: 100%;
padding-bottom: 4px;
}
.hz__frame {
padding: 8px;
}
.hz__screen {
padding: 4px;
}
.hz__spot--1 {
left: 18%;
top: 30%;
}
.hz__spot--2 {
left: 24%;
top: 66%;
}
.hz__note {
left: 12px;
padding: 0 4px;
}
.hz__dot {
width: 16px;
height: 16px;
left: -8px;
top: -8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hz__art, .hz__dot, .hz__ring, .hz__note {
animation: none !important;
transition: none !important;
}
.hz.is-demo .hz__note {
opacity: 1 !important;
}
}(function () {
var root = document.querySelector('.hz');
var spots = [].slice.call(root.querySelectorAll('.hz__spot'));
// 격자 미리보기용 자동 루프 — 사람이 손을 대면 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
spots.forEach(function (spot) {
var dot = spot.querySelector('.hz__dot');
dot.addEventListener('click', function () {
stopLoop();
var isOpen = dot.getAttribute('aria-expanded') === 'true';
dot.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
spot.classList.toggle('is-open', !isOpen);
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroZoomHotspot.module.scss";
export type Hotspot = {
/** 점의 이름 — 스크린 리더가 읽는다 */
label: string;
/** 터진 자리에 뜨는 쪽지 */
note: string;
};
export type HeroZoomHotspotProps = {
title?: string;
sub?: string;
spots?: [Hotspot, Hotspot];
/** 확대 한 바퀴 · 호출점 둘이 차례로 터지는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 제품 화면 타일의 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Crosshair = (): ReactNode => (
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M160,128a32,32,0,1,1-32-32A32,32,0,0,1,160,128Z" opacity="0.2" />
<path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z" />
</svg>
);
const DEFAULT_SPOTS: [Hotspot, Hotspot] = [
{ label: "지표 설명 열기", note: "지표를 한눈에" },
{ label: "알림 설명 열기", note: "알림만 골라서" },
];
export default function HeroZoomHotspot({
title = "기능은 짚어서 보여 준다",
sub = "점을 누르면 그 자리에 설명 쪽지가 뜹니다.",
spots = DEFAULT_SPOTS,
duration = 2000,
easing = "ease-out",
color = "#5b4bd6",
}: HeroZoomHotspotProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [open, setOpen] = useState<boolean[]>([false, false]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const place = [styles.spot1, styles.spot2];
/* 점을 누르면 그 쪽지만 열리고 닫힌다 — 미리보기 루프는 그 자리에서 뗀다 */
const toggle = (i: number): void => {
setLoop(false);
setOpen(open.map((v: boolean, k: number) => (k === i ? !v : v)));
};
return (
<section
className={loop ? `${styles.hz} ${styles.isDemo}` : styles.hz}
style={vars}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.copy}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.sub}>{sub}</p>
</div>
<div className={styles.frame}>
{/* 제품 그림은 사진이 아니라 도형 조합 — transform-origin 을 첫 호출점 쪽에 둔다 */}
<div className={styles.art} aria-hidden="true">
<span className={styles.screen}>
<i className={`${styles.bar} ${styles.barHead}`} />
<i className={`${styles.bar} ${styles.barA}`} />
<span className={styles.row}>
<i className={`${styles.tile} ${styles.tileV}`} />
<i className={`${styles.tile} ${styles.tileG}`} />
</span>
</span>
</div>
{spots.map((s: Hotspot, i: number) => (
<div
className={`${styles.spot} ${place[i]} ${open[i] ? styles.isOpen : ""}`}
key={s.label}
>
<button
className={styles.dot}
type="button"
aria-expanded={open[i]}
aria-controls={`hz-note-${i + 1}`}
aria-label={s.label}
onClick={() => toggle(i)}
>
<span className={styles.ring} aria-hidden="true" />
<Crosshair />
</button>
<p className={styles.note} id={`hz-note-${i + 1}`}>{s.note}</p>
</div>
))}
</div>
</section>
);
}05 · Product zoom hotspot hero — Measured area 10.767% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It belongs on product detail screens that point features out, and it ran 21 of 24 frames at intensity 64.5.
.hz.is-demo .hz__note {
visibility: visible;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
transition: none;
}
@keyframes hzZoom {
0% { transform: scale(1); }
52% { transform: scale(1.12); }
88%, 100% { transform: scale(1); }
}
@keyframes hzNote1 {
0% { opacity: 0; }
26% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
06Stat counter hero
What moves
Three figures roll seven-digit strips like a slot machine on steps(6, end), so only whole digits ever pass the window while the cells stagger .16s and .32s and the second window of each figure trails a further .08s. When the roll stops at 60% of the loop, an underline fills from the left to mark each value as settled, and font-variant-numeric: tabular-nums keeps the figures from jittering as the digits change.

<main class="stage">
<section class="hc is-demo">
<h1 class="hc__title">The company, in numbers first</h1>
<div class="hc__row">
<div class="hc__cell hc__cell--1">
<!-- 자릿수 스트립: 세로로 쌓은 숫자 열을 창 안에서 굴려 올린다. 읽히는 값은 aria-label 하나 -->
<p class="hc__value" role="img" aria-label="98 percent">
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">3</span><span class="hc__digit">4</span><span class="hc__digit">5</span><span class="hc__digit">6</span><span class="hc__digit">7</span><span class="hc__digit">8</span><span class="hc__digit">9</span></span></span>
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">2</span><span class="hc__digit">3</span><span class="hc__digit">4</span><span class="hc__digit">5</span><span class="hc__digit">6</span><span class="hc__digit">7</span><span class="hc__digit">8</span></span></span>
<span class="hc__unit" aria-hidden="true">%</span>
</p>
<span class="hc__rule" aria-hidden="true"></span>
<p class="hc__label">Uptime</p>
</div>
<div class="hc__cell hc__cell--2">
<p class="hc__value" role="img" aria-label="12 times">
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">5</span><span class="hc__digit">6</span><span class="hc__digit">7</span><span class="hc__digit">8</span><span class="hc__digit">9</span><span class="hc__digit">0</span><span class="hc__digit">1</span></span></span>
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">6</span><span class="hc__digit">7</span><span class="hc__digit">8</span><span class="hc__digit">9</span><span class="hc__digit">0</span><span class="hc__digit">1</span><span class="hc__digit">2</span></span></span>
<span class="hc__unit" aria-hidden="true">×</span>
</p>
<span class="hc__rule" aria-hidden="true"></span>
<p class="hc__label">Release speed</p>
</div>
<div class="hc__cell hc__cell--3">
<p class="hc__value" role="img" aria-label="24 hours">
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">6</span><span class="hc__digit">7</span><span class="hc__digit">8</span><span class="hc__digit">9</span><span class="hc__digit">0</span><span class="hc__digit">1</span><span class="hc__digit">2</span></span></span>
<span class="hc__win" aria-hidden="true"><span class="hc__strip"><span class="hc__digit">8</span><span class="hc__digit">9</span><span class="hc__digit">0</span><span class="hc__digit">1</span><span class="hc__digit">2</span><span class="hc__digit">3</span><span class="hc__digit">4</span></span></span>
<span class="hc__unit" aria-hidden="true">h</span>
</p>
<span class="hc__rule" aria-hidden="true"></span>
<p class="hc__label">Avg. response</p>
</div>
</div>
<button class="hc__again" type="button">Count again</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 옐로 무대 위 글자는 전부 잉크 (12.63:1)
$hr-paper: $stage-paper; // 버튼 글자
$hr-yellow: $stage-yellow; // 무대
$hc-cell: 1.15em; // 한 자릿수 칸의 높이 — 스트립은 이 단위로만 굴러간다
$duration: $dur-loop; // 굴림 + 확정 밑줄 한 바퀴
$easing: $ease-spring; // 밑줄이 차오르는 결
$color: $hr-ink; // 옐로 무대 위 강조는 잉크뿐
@include stage($hr-yellow);
.hc {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: clamp(8px, 2.4vw, 12px);
width: clamp(272px, 94vw, 432px);
text-align: center;
}
.hc__title {
margin: 0;
color: $color; // 잉크 on 옐로 12.63:1
font-weight: 800; font-size: clamp(13px, 3.4vw, 20px); line-height: 1.2;
letter-spacing: -.01em;
word-break: keep-all; // 한국어는 낱말 단위로 줄을 바꾼다
}
// 세 칸은 1fr 로 정확히 균등하게 나눈다 (BD19 오탐 회피이자 칸 폭이 흔들리지 않는 정답)
.hc__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(8px, 3vw, 16px);
width: 100%;
}
.hc__cell {
--d: 0s; // 칸마다 굴림이 어긋나는 만큼
display: grid;
justify-items: center;
gap: $sp-1;
min-width: 0;
}
.hc__cell--2 { --d: .16s; }
.hc__cell--3 { --d: .32s; }
// 정적 상태 — 스트립 기본 위치가 곧 최종 숫자다. 루프는 그 위에 덧씌운다
.hc__value {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0;
margin: 0;
color: $color;
font-variant-numeric: tabular-nums; // 자릿수 폭이 흔들리지 않는다
font-weight: 800; font-size: clamp(20px, 6vw, 32px); line-height: 1;
letter-spacing: -.02em;
}
.hc__win {
display: block;
overflow: hidden;
height: $hc-cell;
}
.hc__strip {
display: block;
transform: translateY($hc-cell * -6); // 일곱 칸 중 마지막 = 확정된 숫자
}
.hc__digit {
display: block;
height: $hc-cell;
line-height: $hc-cell;
}
.hc__unit {
font-weight: 700; font-size: .56em; line-height: 1.6;
}
// 확정 밑줄 — 정적 상태에서는 이미 차 있다
.hc__rule {
width: 100%;
height: 3px;
border-radius: $r-pill;
background: $color;
transform: scaleX(1);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 기준 채움
}
.hc__label {
margin: 0;
color: $color;
font-weight: 600; font-size: clamp(9px, 2.2vw, 12px); line-height: 1.2;
}
.hc__again {
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $hr-ink;
color: $hr-paper; // 흰 글자 on 잉크 18.24:1
font-weight: 700; font-size: clamp(10px, 2.4vw, 12px); line-height: 1.2;
cursor: pointer;
}
.hc__again:focus-visible {
outline: 2px solid $hr-ink;
outline-offset: 2px;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 스트립은 steps(6, end) 로 굴러 매 프레임 온전한 숫자만 보이고, 굴림이 멈추면 밑줄이 차오른다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.hc.is-demo .hc__strip {
animation-name: hcRoll;
animation-duration: $duration;
animation-timing-function: steps(6, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
// 한 칸 안에서도 뒷자리가 조금 늦게 선다
.hc.is-demo .hc__win:nth-child(2) .hc__strip { animation-delay: calc(var(--d) + .08s); }
.hc.is-demo .hc__rule {
animation-name: hcRule;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
@keyframes hcRoll {
0% { transform: translateY(0); }
60% { transform: translateY($hc-cell * -6); }
100% { transform: translateY($hc-cell * -6); }
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이다
@keyframes hcRule {
0%, 58% { transform: scaleX(0); }
72%, 86% { transform: scaleX(1); }
90%, 100% { transform: scaleX(0); }
}
// 좁은 화면 — 세 칸을 유지하고 글자·여백만 줄인다 (이 안에서는 간격을 올리지 않는다)
@media (max-width: 340px), (max-height: 220px) {
.hc { gap: $sp-2; width: 100%; }
.hc__row { gap: $sp-1; }
.hc__cell { gap: 0; }
.hc__rule { height: 2px; }
.hc__again { padding: 0 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// .hc.is-demo .hc__strip (0,3,0) 를 이기려면 !important 가 필요하다 —
// 애니메이션이 꺼져도 기본 위치가 최종 숫자라 값과 라벨은 그대로 보인다
.hc__strip, .hc__rule { animation: none !important; }
}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);
}
.hc {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: clamp(8px, 2.4vw, 12px);
width: clamp(272px, 94vw, 432px);
text-align: center;
}
.hc__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 20px);
line-height: 1.2;
letter-spacing: -0.01em;
word-break: keep-all;
}
.hc__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(8px, 3vw, 16px);
width: 100%;
}
.hc__cell {
--d: 0s;
display: grid;
justify-items: center;
gap: 4px;
min-width: 0;
}
.hc__cell--2 {
--d: .16s;
}
.hc__cell--3 {
--d: .32s;
}
.hc__value {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0;
margin: 0;
color: #17141a;
font-variant-numeric: tabular-nums;
font-weight: 800;
font-size: clamp(20px, 6vw, 32px);
line-height: 1;
letter-spacing: -0.02em;
}
.hc__win {
display: block;
overflow: hidden;
height: 1.15em;
}
.hc__strip {
display: block;
transform: translateY(-6.9em);
}
.hc__digit {
display: block;
height: 1.15em;
line-height: 1.15em;
}
.hc__unit {
font-weight: 700;
font-size: 0.56em;
line-height: 1.6;
}
.hc__rule {
width: 100%;
height: 3px;
border-radius: 999px;
background: #17141a;
transform: scaleX(1);
transform-origin: left center;
}
.hc__label {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(9px, 2.2vw, 12px);
line-height: 1.2;
}
.hc__again {
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: clamp(10px, 2.4vw, 12px);
line-height: 1.2;
cursor: pointer;
}
.hc__again:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.hc.is-demo .hc__strip {
animation-name: hcRoll;
animation-duration: 2s;
animation-timing-function: steps(6, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
.hc.is-demo .hc__win:nth-child(2) .hc__strip {
animation-delay: calc(var(--d) + 0.08s);
}
.hc.is-demo .hc__rule {
animation-name: hcRule;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
@keyframes hcRoll {
0% {
transform: translateY(0);
}
60% {
transform: translateY(-6.9em);
}
100% {
transform: translateY(-6.9em);
}
}
@keyframes hcRule {
0%, 58% {
transform: scaleX(0);
}
72%, 86% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hc {
gap: 8px;
width: 100%;
}
.hc__row {
gap: 4px;
}
.hc__cell {
gap: 0;
}
.hc__rule {
height: 2px;
}
.hc__again {
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hc__strip, .hc__rule {
animation: none !important;
}
}(function () {
var root = document.querySelector('.hc');
var row = root.querySelector('.hc__row');
var again = root.querySelector('.hc__again');
// 격자 미리보기용 자동 루프 — 숫자 칸에 손이 닿으면 뗀다 (버튼은 예외라 눌러서 다시 셀 수 있다)
function stopLoop() { root.classList.remove('is-demo'); }
again.addEventListener('click', function () {
root.classList.remove('is-demo');
void root.offsetWidth; // 리플로우 — 같은 애니메이션을 처음부터 다시 건다
root.classList.add('is-demo');
});
row.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', function (e) {
if (e.target !== again) { stopLoop(); }
});
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroStatCounter.module.scss";
/* ambient 에 FocusEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type FocusEvt = { target: unknown; currentTarget: unknown };
export type Stat = {
/** 자릿수 스트립 — 마지막 칸이 확정된 숫자다 */
digits: string[][];
/** 숫자 뒤 단위 (%, ×, h …) */
unit: string;
/** 스크린 리더가 읽을 값 */
value: string;
label: string;
};
export type HeroStatCounterProps = {
title?: string;
stats?: Stat[];
againLabel?: string;
/** 굴림 + 확정 밑줄 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 옐로 무대 위 강조는 잉크뿐 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_STATS: Stat[] = [
{ digits: [["3", "4", "5", "6", "7", "8", "9"], ["2", "3", "4", "5", "6", "7", "8"]], unit: "%", value: "98퍼센트", label: "가동 안정성" },
{ digits: [["5", "6", "7", "8", "9", "0", "1"], ["6", "7", "8", "9", "0", "1", "2"]], unit: "×", value: "12배", label: "배포 속도" },
{ digits: [["6", "7", "8", "9", "0", "1", "2"], ["8", "9", "0", "1", "2", "3", "4"]], unit: "h", value: "24시간", label: "평균 응답" },
];
export default function HeroStatCounter({
title = "숫자가 먼저 말하는 회사 소개",
stats = DEFAULT_STATS,
againLabel = "다시 세기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: HeroStatCounterProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
/* 같은 애니메이션을 처음부터 다시 걸기 위한 열쇠 — 값이 바뀌면 노드가 새로 선다 */
const [run, setRun] = useState<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const delay = ["0s", ".16s", ".32s"];
const again = (): void => {
setLoop(true);
setRun(run + 1);
};
return (
<section
className={loop ? `${styles.hc} ${styles.isDemo}` : styles.hc}
style={vars}
onFocus={(e: FocusEvt) => { if (e.target !== e.currentTarget) { setLoop(false); } }}
>
<h1 className={styles.title}>{title}</h1>
<div className={styles.row} key={run} onPointerEnter={() => setLoop(false)}>
{stats.map((s: Stat, i: number) => (
<div className={styles.cell} key={s.label} style={{ "--d": delay[i % delay.length] } as CSSProperties}>
{/* 자릿수 스트립은 창 안에서 굴러간다 — 읽히는 값은 aria-label 하나 */}
<p className={styles.value} role="img" aria-label={s.value}>
{s.digits.map((col: string[], k: number) => (
<span className={styles.win} aria-hidden="true" key={k}>
<span className={k === 1 ? `${styles.strip} ${styles.stripB}` : styles.strip}>
{col.map((d: string, n: number) => (
<span className={styles.digit} key={n}>{d}</span>
))}
</span>
</span>
))}
<span className={styles.unit} aria-hidden="true">{s.unit}</span>
</p>
<span className={styles.rule} aria-hidden="true" />
<p className={styles.label}>{s.label}</p>
</div>
))}
</div>
<button className={styles.again} type="button" onClick={again}>{againLabel}</button>
</section>
);
}06 · Stat counter hero — Measured area 2.145% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It is the smallest mover by area at 2.145% and the densest by sampling at intensity 147.3, and each value is read out by one aria-label while the rolling digits stay aria-hidden.
$hc-cell: 1.15em; // one digit cell high — the strip rolls only in whole cells
.hc__strip {
display: block;
transform: translateY($hc-cell * -6); // of the seven digits, the last is the settled value
}
.hc.is-demo .hc__strip {
animation-name: hcRoll;
animation-duration: $duration;
animation-timing-function: steps(6, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
@keyframes hcRoll {
0% { transform: translateY(0); }
60% { transform: translateY($hc-cell * -6); }
100% { transform: translateY($hc-cell * -6); }
}
07Scroll-folding hero
What you see
As you scroll, the hero stays pinned while it shrinks to .86 scale and rounds its corners, a dim layer deepens to .28 opacity, and the body card climbs 20px over the hero to cover it. The whole handover is three animations on one named scroll timeline, scroll-timeline-name: --hfscroll on the scroller and animation-timeline: --hfscroll on each piece, run with linear easing and animation-fill-mode: both so scrolling past the end never snaps anything back.

<main class="stage">
<div class="hf">
<!-- 자체 스크롤 상자. 이 상자가 스크롤포트이자 타임라인(--hfscroll)의 주인이다.
html·body 는 절대 스크롤되지 않는다 -->
<div class="hf__scroller" tabindex="0" role="region" aria-label="First-screen scroll box">
<section class="hf__hero">
<div class="hf__inner">
<h1 class="hf__title">One scroll to the body</h1>
<p class="hf__sub">The first screen pins in place and folds</p>
<button class="hf__btn" type="button">
Go to body
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor"><path d="M200,144l-72,72L56,144Z" opacity="0.2"/><path d="M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z"/></svg>
</button>
</div>
<div class="hf__dim" aria-hidden="true"></div>
</section>
<article class="hf__doc">
<h2 class="hf__h2">Body starts here</h2>
<p class="hf__p">This card climbs over the folded first screen.</p>
<p class="hf__p hf__p--soft">Arrow keys reach the same spot.</p>
</article>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 무대 · 히어로 바닥
$hr-paper: $stage-paper; // 제목 글자 on 잉크 18.24:1
$hr-cream: #fdf7ec; // 본문 카드 면 (잉크 글자 17.10:1)
$hr-stone: #56505e; // 크림 위 보조 글자 7.28:1
$hr-mist: #b6b0c0; // 잉크 위 보조 글자 8.65:1
$hr-yellow: $stage-yellow; // 버튼 면 (잉크 글자 12.63:1)
$hr-lilac: #d8d6eb; // 잉크 위 강조 6.99:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 버튼·포커스 같은 손맛. 접히는 동작의 길이는 스크롤 양이 정한다
$easing: linear; // 스크롤 구동은 등속이라야 스크롤 양과 화면이 1:1 로 맞는다
$color: $hr-lilac; // 이 항목의 강조색
@include stage($hr-ink);
.hf { width: min(392px, 90vw); }
// ⚠ 타임라인에 **이름**을 붙인다. `scroll(nearest)` 는 `overflow: hidden` 인 조상도
// 스크롤포트로 잡아, 진행률이 영원히 0 인 타임라인에 묶이는 일이 있다 (260 02·03·04 실측).
// position: relative 는 본문 카드의 offsetTop 기준을 이 상자로 못 박기 위한 것
.hf__scroller {
--h: clamp(148px, 58vh, 176px);
position: relative;
height: var(--h);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: $r-card;
background: $hr-ink;
scroll-timeline-name: --hfscroll;
scroll-timeline-axis: y;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
// ── 실제 동작 ① 히어로 판은 제자리에 붙는다 ─────────────
// sticky 의 여유는 **부모의 콘텐츠 박스**가 정한다 — 부모에 padding-bottom 을 줘 봐야
// sticky 범위 밖이라 안 먹는다 (260 03·06·07·08 실측). 여기서는 본문 카드의 height 가 그 여유다
.hf__hero {
position: sticky;
top: 0;
z-index: 0;
display: grid;
align-content: center;
height: calc(var(--h) * .76);
padding: 0 $sp-5;
overflow: hidden;
background: rgba($hr-lilac, .2);
transform-origin: top center; // 규칙8: 윗변 경첩 접힘 축소
// ── 실제 동작 ② 스크롤 진행이 곧 접힘. 한 방향 키프레임 + fill-mode both 라
// 더 내려도 되돌아가거나 스냅백하지 않는다 (260-05 실측)
animation-name: hfFold;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
.hf__inner { display: grid; gap: $sp-2; justify-items: start; }
.hf__title {
margin: 0;
color: $hr-paper;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 21px);
line-height: 1.2;
letter-spacing: -.02em;
}
.hf__sub {
margin: 0;
color: $hr-mist;
font-weight: 500;
font-size: 12px;
line-height: 1.35;
}
.hf__btn {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-2 $sp-3;
border: 0;
border-radius: $r-control;
background: $hr-yellow;
color: $hr-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: transform $duration $ease-spring;
}
.hf__btn:hover { transform: translateY(-2px); }
.hf__btn:focus-visible { outline: 2px solid $hr-paper; outline-offset: 2px; }
// 접히면서 살짝 어두워진다 — 여기 얹는 잉크는 얇게(.28). 두껍게 덮으면 제목이 읽히지 않는다
.hf__dim {
position: absolute;
inset: 0;
pointer-events: none; // 안 주면 히어로 전체를 덮은 이 막이 버튼 클릭을 가로챈다 (Playwright 실측)
background: $hr-ink;
opacity: 0;
animation-name: hfDim;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
// ── 실제 동작 ③ 본문 카드가 히어로 위로 올라와 덮는다 ───
// 음수 margin-top 으로 늘 히어로 아랫단을 물고 있으므로, 히어로가 작아져도 둘 사이가 벌어지지 않는다.
// 카드 높이를 상자 높이(--h)와 같게 두면 **스크롤 여유 = 카드의 시작 위치** 가 되어,
// 다 내렸을 때 카드가 히어로를 정확히 덮는다. 더 길게 두면 절반쯤에서 이미 다 덮여 접힘이 안 보인다
.hf__doc {
position: relative;
z-index: 1;
box-sizing: border-box; // 전역 리셋이 없다 — 안 주면 min-height 에 padding 이 더해져 카드가 40px 더 길어진다
min-height: calc(var(--h) - #{$sp-5});
margin-top: -$sp-6;
// 위 여백을 24px 로 둬야 스크롤 0 에서 보이는 카드 조각이 제목 뒤 **빈 줄**에서 끊긴다
// (16px 이면 둘째 줄 글자가 상자 밑변에 반쯤 걸려 잘린 것처럼 보인다)
padding: $sp-6 $sp-5;
border-radius: $r-card $r-card 0 0;
background: $hr-cream;
color: $hr-ink;
box-shadow: $shadow-raised;
animation-name: hfRise;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
.hf__h2 {
margin: 0 0 $sp-2;
font-weight: 800;
font-size: 14px;
line-height: 1.2;
}
.hf__p {
margin: 0 0 $sp-2;
font-weight: 500;
font-size: 12px;
line-height: 1.5;
}
.hf__p--soft { margin-bottom: 0; color: $hr-stone; }
@keyframes hfFold {
from { transform: scale(1); border-radius: 0; }
to { transform: scale(.86); border-radius: $r-card; }
}
@keyframes hfDim {
from { opacity: 0; }
to { opacity: .28; }
}
// 카드는 스크롤보다 조금 늦게 따라온다(시차) — 끝에서 transform 이 0 이라 아래가 비지 않는다
@keyframes hfRise {
from { transform: translateY(20px); }
to { transform: translateY(0); }
}
// 좁은 화면 — 글자와 여백만 줄인다. 값을 올리지 않는다 (올리면 174px 여유를 넘긴다)
@media (max-width: 340px), (max-height: 220px) {
.hf__hero { padding: 0 $sp-4; }
.hf__sub { font-size: 11px; }
.hf__doc { padding: $sp-3 $sp-4 $sp-4; }
.hf__p { font-size: 11px; }
}
// 스크롤 구동 애니메이션(scroll-timeline-name·animation-timeline)은 caniuse 실측 2026-09-12 기준
// Chrome·Edge 115+ · Safari 26.0 · iOS Safari 26.0 지원, 전역 87.22%. 안정판에서 아직 못 쓰는 건
// Firefox 뿐이다(155, 플래그 뒤) — 거기서는 접힘 없이 히어로가 그대로 서 있고 카드가 위로 지나간다.
// 정적 상태를 먼저 쓴 이유가 이것이다
@media (prefers-reduced-motion: reduce) {
// .hf__hero (0,1,0) 와 같은 특이도라도 뒤에 오는 이 블록이 이기지만, 안전하게 !important
.hf__hero, .hf__dim, .hf__doc { animation: none !important; }
.hf__hero { border-radius: $r-card $r-card 0 0; }
.hf__doc { transform: none !important; }
.hf__btn { 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);
}
.hf {
width: min(392px, 90vw);
}
.hf__scroller {
--h: clamp(148px, 58vh, 176px);
position: relative;
height: var(--h);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 16px;
background: #17141a;
scroll-timeline-name: --hfscroll;
scroll-timeline-axis: y;
scrollbar-width: none;
}
.hf__scroller::-webkit-scrollbar {
display: none;
}
.hf__scroller:focus-visible {
outline: 2px solid #d8d6eb;
outline-offset: 2px;
}
.hf__hero {
position: sticky;
top: 0;
z-index: 0;
display: grid;
align-content: center;
height: calc(var(--h) * 0.76);
padding: 0 20px;
overflow: hidden;
background: rgba(216, 214, 235, 0.2);
transform-origin: top center;
animation-name: hfFold;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
.hf__inner {
display: grid;
gap: 8px;
justify-items: start;
}
.hf__title {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 21px);
line-height: 1.2;
letter-spacing: -0.02em;
}
.hf__sub {
margin: 0;
color: #b6b0c0;
font-weight: 500;
font-size: 12px;
line-height: 1.35;
}
.hf__btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 8px 12px;
border: 0;
border-radius: 12px;
background: #ffd23f;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hf__btn:hover {
transform: translateY(-2px);
}
.hf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.hf__dim {
position: absolute;
inset: 0;
pointer-events: none;
background: #17141a;
opacity: 0;
animation-name: hfDim;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
.hf__doc {
position: relative;
z-index: 1;
box-sizing: border-box;
min-height: calc(var(--h) - 20px);
margin-top: -24px;
padding: 24px 20px;
border-radius: 16px 16px 0 0;
background: #fdf7ec;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
animation-name: hfRise;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
.hf__h2 {
margin: 0 0 8px;
font-weight: 800;
font-size: 14px;
line-height: 1.2;
}
.hf__p {
margin: 0 0 8px;
font-weight: 500;
font-size: 12px;
line-height: 1.5;
}
.hf__p--soft {
margin-bottom: 0;
color: #56505e;
}
@keyframes hfFold {
from {
transform: scale(1);
border-radius: 0;
}
to {
transform: scale(0.86);
border-radius: 16px;
}
}
@keyframes hfDim {
from {
opacity: 0;
}
to {
opacity: 0.28;
}
}
@keyframes hfRise {
from {
transform: translateY(20px);
}
to {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hf__hero {
padding: 0 16px;
}
.hf__sub {
font-size: 11px;
}
.hf__doc {
padding: 12px 16px 16px;
}
.hf__p {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.hf__hero, .hf__dim, .hf__doc {
animation: none !important;
}
.hf__hero {
border-radius: 16px 16px 0 0;
}
.hf__doc {
transform: none !important;
}
.hf__btn {
transition: none !important;
}
}(function () {
var box = document.querySelector('.hf__scroller');
var doc = document.querySelector('.hf__doc');
var btn = document.querySelector('.hf__btn');
// 접근성: 움직임을 줄이겠다고 한 사람에게는 부드러운 스크롤 대신 즉시 이동
function prefersInstant() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
// 상자 안 본문 카드 자리로 옮긴다. offsetTop 은 position: relative 인 상자 기준이고
// 스크롤 양과 무관한 값이라 누를 때마다 같은 자리로 간다
btn.addEventListener('click', function () {
box.scrollTo({ top: doc.offsetTop, behavior: prefersInstant() ? 'auto' : 'smooth' });
});
// 상자가 tabindex="0" 이라 Tab 으로 잡히고, 잡힌 뒤엔 방향키·PageDown 으로 브라우저가 스크롤한다.
// Home·End 만 따로 받아 양 끝으로 보낸다
box.addEventListener('keydown', function (e) {
if (e.key !== 'Home' && e.key !== 'End') { return; }
e.preventDefault();
box.scrollTo({ top: e.key === 'Home' ? 0 : box.scrollHeight, behavior: prefersInstant() ? 'auto' : 'smooth' });
});
})();import { useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroScrollFold.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type HeroScrollFoldProps = {
title?: string;
sub?: string;
cta?: string;
docTitle?: string;
docBody?: string;
docNote?: string;
/** 스크롤 상자를 읽어 줄 이름 */
regionLabel?: string;
/** 버튼·포커스 같은 손맛(ms). 접히는 길이는 스크롤 양이 정한다 */
duration?: number;
easing?: string;
/** 강조색 — 히어로 바닥의 엷은 막과 포커스 테두리 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ArrowDownIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M200,144l-72,72L56,144Z" opacity="0.2" />
<path d="M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z" />
</svg>
);
export default function HeroScrollFold({
title = "스크롤 한 번으로 본문까지",
sub = "첫 화면은 제자리에 붙어 접힙니다",
cta = "본문으로 이동",
docTitle = "본문 시작",
docBody = "접힌 첫 화면 위로 이 카드가 올라와 덮습니다.",
docNote = "방향키로도 같은 자리를 지납니다.",
regionLabel = "첫 화면 스크롤 상자",
duration = 220,
easing = "linear",
color = "#9d93ff",
}: HeroScrollFoldProps): ReactNode {
const boxRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 접근성: 움직임을 줄이겠다고 한 사람에게는 부드러운 스크롤 대신 즉시 이동 */
const prefersInstant = (): boolean =>
!!(typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
/* offsetTop 은 position: relative 인 상자 기준이고 스크롤 양과 무관하다 */
const goToBody = (): void => {
const box = boxRef.current;
const doc = docRef.current;
if (!box || !doc) { return; }
box.scrollTo({ top: doc.offsetTop, behavior: prefersInstant() ? "auto" : "smooth" });
};
const onKey = (e: KeyEvt): void => {
const box = boxRef.current;
if (!box || (e.key !== "Home" && e.key !== "End")) { return; }
e.preventDefault();
box.scrollTo({ top: e.key === "Home" ? 0 : box.scrollHeight, behavior: prefersInstant() ? "auto" : "smooth" });
};
return (
<div className={styles.hf} style={vars}>
{/* 자체 스크롤 상자 = 스크롤포트이자 타임라인(--hfscroll)의 주인 */}
<div
className={styles.scroller}
ref={boxRef}
tabIndex={0}
role="region"
aria-label={regionLabel}
onKeyDown={(e: KeyEvt) => onKey(e)}
>
<section className={styles.hero}>
<div className={styles.inner}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.sub}>{sub}</p>
<button className={styles.btn} type="button" onClick={goToBody}>
{cta}
<ArrowDownIcon />
</button>
</div>
<div className={styles.dim} aria-hidden="true" />
</section>
<article className={styles.doc} ref={docRef}>
<h2 className={styles.h2}>{docTitle}</h2>
<p className={styles.p}>{docBody}</p>
<p className={`${styles.p} ${styles.pSoft}`}>{docNote}</p>
</article>
</div>
</div>
);
}07 · Scroll-folding hero — Measured area 19.888% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It is the seam between the first screen and the page body, and it moved in 23 of 24 frames for 21.196% cumulative changed area.
.hf__scroller {
--h: clamp(148px, 58vh, 176px);
position: relative;
height: var(--h);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: $r-card;
background: $hr-ink;
scroll-timeline-name: --hfscroll;
scroll-timeline-axis: y;
}
.hf__hero {
position: sticky;
top: 0;
z-index: 0;
display: grid;
align-content: center;
height: calc(var(--h) * .76);
transform-origin: top center;
animation-name: hfFold;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --hfscroll;
}
08Split slider hero
What happens on screen
Three slides sit stacked in a single grid cell, and on each change the text pane wipes in from the left while the picture pane wipes in from the right, opposite clip-path insets on layers sharing grid-area: 1 / 1, with the incoming title riding up 10px. Z-order changes only as steps(1, end) cuts, so a frame holding two half-visible slides never exists, and the dot indicators fill on cuts timed to the wipes rather than on the z-order boundaries.

<main class="stage">
<section class="hl is-demo" aria-label="Product slides">
<!-- 세 장이 같은 격자 칸(grid-area: 1 / 1)에 포개진다. 복제본 없이 z 순서와 위프만으로 순환한다 -->
<div class="hl__deck">
<article class="hl__slide hl__slide--1 is-on">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
Spring pick
</p>
<h2 class="hl__title">One light jacket</h2>
<p class="hl__sub">A windbreaker for dawn and dusk</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art1" aria-hidden="true">
<span class="hl__shape hl__shape--disc"></span>
<span class="hl__shape hl__shape--bar"></span>
</div>
</article>
<article class="hl__slide hl__slide--2">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
Summer pick
</p>
<h2 class="hl__title">A hat that packs its own shade</h2>
<p class="hl__sub">Folds into your bag</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art2" aria-hidden="true">
<span class="hl__shape hl__shape--arch"></span>
<span class="hl__shape hl__shape--dot"></span>
</div>
</article>
<article class="hl__slide hl__slide--3">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
Autumn pick
</p>
<h2 class="hl__title">A blanket woven in two layers</h2>
<p class="hl__sub">Sized right for your lap</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art3" aria-hidden="true">
<span class="hl__shape hl__shape--wedge"></span>
<span class="hl__shape hl__shape--ring"></span>
</div>
</article>
</div>
<div class="hl__dots" role="group" aria-label="Choose a slide">
<button class="hl__dot hl__dot--1" type="button" aria-current="true" aria-label="Go to slide one"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--2" type="button" aria-label="Go to slide two"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--3" type="button" aria-label="Go to slide three"><span class="hl__fill"></span></button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 오렌지 위 글자 5.50:1 · 그림 면 바닥
$hr-cream: #fdf7ec; // 그림 면 바닥 (잉크 도형 17.10:1)
$hr-orange: $stage-orange; // 무대 · 글자 면 바닥
$hr-yellow: $stage-yellow; // 그림 면 바닥 (잉크 도형 12.63:1)
$hr-violet: #9791c9; // 브랜드 강조 (크림 위 5.75:1)
$hr-lilac: #d8d6eb; // 잉크 위 강조 6.99:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 장이 한 바퀴 도는 시간
$easing: $ease-spring; // 닦이듯 드러나는 위프
$color: $hr-violet; // 이 항목의 강조색
@include stage($hr-orange);
.hl {
display: grid;
gap: $sp-3;
width: min(400px, 90vw);
}
// 세 장을 **같은 격자 칸**에 포갠다 — 복제본 없이 z 순서와 위프만으로 순환한다
.hl__deck {
display: grid;
height: clamp(120px, 46vh, 148px);
overflow: hidden;
border-radius: $r-card;
}
.hl__slide {
grid-area: 1 / 1;
z-index: 1;
display: grid;
grid-template-columns: 1fr 38%;
}
// ── 실제 동작 ① 위프 — 왼쪽 면은 왼쪽에서, 오른쪽 면은 오른쪽에서 반대로 닦인다 ──
// opacity 크로스페이드는 쓰지 않는다: 교차 구간 프레임이 전부 반투명 유령이 된다 (237·266 실측)
.hl__pane {
box-sizing: border-box;
transition: clip-path $dur-enter $easing;
}
.hl__pane--copy { clip-path: inset(0 100% 0 0); }
.hl__pane--art { clip-path: inset(0 0 0 100%); }
// 정적 상태(루프 밖) — 지금 장은 맨 위에서 다 보이고, 직전 장은 그 아래에 그대로 남는다.
// 직전 장을 남겨 둬야 새 장이 덮는 동안 바닥이 비지 않는다
.hl__slide.is-prev { z-index: 2; }
.hl__slide.is-on { z-index: 3; }
.hl__slide.is-prev .hl__pane,
.hl__slide.is-on .hl__pane { clip-path: inset(0 0 0 0); }
.hl__pane--copy {
display: grid;
align-content: center;
gap: $sp-1;
padding: $sp-4;
background: $hr-orange;
color: $hr-ink;
}
.hl__kicker {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
letter-spacing: .02em;
}
.hl__title {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1.25;
letter-spacing: -.02em;
}
.hl__sub {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
// 그림 면 — 도형 조합만. 외부 이미지는 쓰지 않는다
.hl__pane--art {
position: relative;
overflow: hidden;
}
.hl__pane--art1 { background: $hr-ink; }
.hl__pane--art2 { background: $hr-cream; }
.hl__pane--art3 { background: $hr-yellow; }
.hl__shape { position: absolute; display: block; }
.hl__shape--disc {
top: 18%; left: 18%;
width: 52%; height: 52%;
border-radius: 50%;
background: $hr-lilac;
}
.hl__shape--bar {
right: 14%; bottom: 16%;
width: 22%; height: 44%;
border-radius: $r-pill;
background: $hr-yellow;
}
.hl__shape--arch {
left: 22%; bottom: 0;
width: 56%; height: 62%;
border-radius: $r-pill $r-pill 0 0;
background: $hr-violet;
}
.hl__shape--dot {
top: 16%; right: 18%;
width: 22%; height: 22%;
border-radius: 50%;
background: $hr-orange;
}
.hl__shape--wedge {
top: 20%; left: 16%;
width: 46%; height: 60%;
background: $hr-ink;
clip-path: polygon(0 100%, 50% 0, 100% 100%);
}
.hl__shape--ring {
right: 12%; bottom: 18%;
width: 30%; height: 30%;
border: 4px solid $hr-violet;
border-radius: 50%;
}
// ── 점 인디케이터 ─────────────────────────────────────
.hl__dots {
display: flex;
justify-content: center;
gap: $sp-2;
}
.hl__dot {
display: grid;
place-items: center;
width: 14px;
height: 14px;
padding: 0;
border: 2px solid $hr-ink;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.hl__dot:focus-visible { outline: 2px solid $hr-ink; outline-offset: 3px; }
.hl__fill {
width: 6px;
height: 6px;
border-radius: 50%;
background: $hr-ink;
transform: scale(0);
}
// 채움은 보간하지 않는다 — 반쯤 찬 중간 프레임을 만들지 않기 위해 컷으로 바꾼다
.hl__dot[aria-current="true"] .hl__fill { transform: scale(1); }
// ── 격자 미리보기 루프 ────────────────────────────────
// 시차는 --d 하나로 준다 (슬라이드에 걸면 그 안의 면·제목이 물려받는다).
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.hl.is-demo .hl__slide--1, .hl.is-demo .hl__dot--1 { --d: -.6667s; }
.hl.is-demo .hl__slide--2, .hl.is-demo .hl__dot--2 { --d: 0s; }
.hl.is-demo .hl__slide--3, .hl.is-demo .hl__dot--3 { --d: -1.3333s; }
.hl.is-demo .hl__slide {
animation-name: hlZ;
animation-duration: $duration;
animation-timing-function: steps(1, end); // z 순서는 컷으로만 바뀐다
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
// ⚠ steps() 키프레임 안에서는 clip-path 가 보간되지 않는다 — 위프는 이징 애니메이션으로 따로 둔다
.hl.is-demo .hl__pane--copy,
.hl.is-demo .hl__pane--art,
.hl.is-demo .hl__title {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
transition: none;
}
.hl.is-demo .hl__pane--copy { animation-name: hlWipeL; }
.hl.is-demo .hl__pane--art { animation-name: hlWipeR; }
.hl.is-demo .hl__title { animation-name: hlPop; }
.hl.is-demo .hl__fill {
animation-name: hlFill;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
// 맨 위(3) → 바로 아래(2) → 바닥(1). 바닥일 때만 숨은 상태로 되돌린다 — 위 두 장이 덮고 있어 안 보인다
@keyframes hlZ {
0%, 33.32% { z-index: 3; }
33.33%, 66.65% { z-index: 2; }
66.66%, 100% { z-index: 1; }
}
@keyframes hlWipeL {
0% { clip-path: inset(0 100% 0 0); }
18%, 66.65% { clip-path: inset(0 0 0 0); }
66.66%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes hlWipeR {
0% { clip-path: inset(0 0 0 100%); }
18%, 66.65% { clip-path: inset(0 0 0 0); }
66.66%, 100% { clip-path: inset(0 0 0 100%); }
}
// 컷만 있으면 움직인 프레임이 모자란다 — 위프와 함께 제목이 살짝 올라오게 둔다
@keyframes hlPop {
0% { transform: translateY(10px); }
18%, 66.65% { transform: translateY(0); }
66.66%, 100% { transform: translateY(10px); }
}
// 점 채움은 위프가 **끝나는** 18% 에 바뀐다 — 0% 에 바꾸면 아직 앞 장이 보이는데 점만 먼저 옮겨간다.
// 24프레임은 4.1667% 간격이라 18%·51.33% 는 프레임 사이에 떨어져 반쯤 찬 장이 안 생긴다
@keyframes hlFill {
0%, 17.99% { transform: scale(0); }
18%, 51.32% { transform: scale(1); }
51.33%, 100% { transform: scale(0); }
}
// 좁은 화면 — 글자와 여백만 줄인다. 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.hl { gap: $sp-2; }
.hl__pane--copy { padding: $sp-3; }
.hl__kicker { font-size: 10px; }
.hl__sub { font-size: 10px; }
.hl__dot { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .hl.is-demo .hl__pane--copy (0,3,0) 를 이기려면 !important 가 필요하다
.hl__slide, .hl__pane, .hl__title, .hl__fill {
animation: none !important;
transition: none !important;
}
// 움직임만 끄고 상태는 남긴다 — 지금 장은 그대로 다 보인다
.hl__slide.is-on .hl__pane { clip-path: inset(0 0 0 0) !important; }
.hl__title { transform: none !important; }
.hl__dot[aria-current="true"] .hl__fill { transform: scale(1) !important; }
}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);
}
.hl {
display: grid;
gap: 12px;
width: min(400px, 90vw);
}
.hl__deck {
display: grid;
height: clamp(120px, 46vh, 148px);
overflow: hidden;
border-radius: 16px;
}
.hl__slide {
grid-area: 1/1;
z-index: 1;
display: grid;
grid-template-columns: 1fr 38%;
}
.hl__pane {
box-sizing: border-box;
transition: clip-path 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hl__pane--copy {
clip-path: inset(0 100% 0 0);
}
.hl__pane--art {
clip-path: inset(0 0 0 100%);
}
.hl__slide.is-prev {
z-index: 2;
}
.hl__slide.is-on {
z-index: 3;
}
.hl__slide.is-prev .hl__pane,
.hl__slide.is-on .hl__pane {
clip-path: inset(0 0 0 0);
}
.hl__pane--copy {
display: grid;
align-content: center;
gap: 4px;
padding: 16px;
background: #ff4d1f;
color: #17141a;
}
.hl__kicker {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.hl__title {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1.25;
letter-spacing: -0.02em;
}
.hl__sub {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.hl__pane--art {
position: relative;
overflow: hidden;
}
.hl__pane--art1 {
background: #17141a;
}
.hl__pane--art2 {
background: #fdf7ec;
}
.hl__pane--art3 {
background: #ffd23f;
}
.hl__shape {
position: absolute;
display: block;
}
.hl__shape--disc {
top: 18%;
left: 18%;
width: 52%;
height: 52%;
border-radius: 50%;
background: #d8d6eb;
}
.hl__shape--bar {
right: 14%;
bottom: 16%;
width: 22%;
height: 44%;
border-radius: 999px;
background: #ffd23f;
}
.hl__shape--arch {
left: 22%;
bottom: 0;
width: 56%;
height: 62%;
border-radius: 999px 999px 0 0;
background: #9791c9;
}
.hl__shape--dot {
top: 16%;
right: 18%;
width: 22%;
height: 22%;
border-radius: 50%;
background: #ff4d1f;
}
.hl__shape--wedge {
top: 20%;
left: 16%;
width: 46%;
height: 60%;
background: #17141a;
clip-path: polygon(0 100%, 50% 0, 100% 100%);
}
.hl__shape--ring {
right: 12%;
bottom: 18%;
width: 30%;
height: 30%;
border: 4px solid #9791c9;
border-radius: 50%;
}
.hl__dots {
display: flex;
justify-content: center;
gap: 8px;
}
.hl__dot {
display: grid;
place-items: center;
width: 14px;
height: 14px;
padding: 0;
border: 2px solid #17141a;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.hl__dot:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.hl__fill {
width: 6px;
height: 6px;
border-radius: 50%;
background: #17141a;
transform: scale(0);
}
.hl__dot[aria-current=true] .hl__fill {
transform: scale(1);
}
.hl.is-demo .hl__slide--1, .hl.is-demo .hl__dot--1 {
--d: -.6667s;
}
.hl.is-demo .hl__slide--2, .hl.is-demo .hl__dot--2 {
--d: 0s;
}
.hl.is-demo .hl__slide--3, .hl.is-demo .hl__dot--3 {
--d: -1.3333s;
}
.hl.is-demo .hl__slide {
animation-name: hlZ;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
.hl.is-demo .hl__pane--copy,
.hl.is-demo .hl__pane--art,
.hl.is-demo .hl__title {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
transition: none;
}
.hl.is-demo .hl__pane--copy {
animation-name: hlWipeL;
}
.hl.is-demo .hl__pane--art {
animation-name: hlWipeR;
}
.hl.is-demo .hl__title {
animation-name: hlPop;
}
.hl.is-demo .hl__fill {
animation-name: hlFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
@keyframes hlZ {
0%, 33.32% {
z-index: 3;
}
33.33%, 66.65% {
z-index: 2;
}
66.66%, 100% {
z-index: 1;
}
}
@keyframes hlWipeL {
0% {
clip-path: inset(0 100% 0 0);
}
18%, 66.65% {
clip-path: inset(0 0 0 0);
}
66.66%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes hlWipeR {
0% {
clip-path: inset(0 0 0 100%);
}
18%, 66.65% {
clip-path: inset(0 0 0 0);
}
66.66%, 100% {
clip-path: inset(0 0 0 100%);
}
}
@keyframes hlPop {
0% {
transform: translateY(10px);
}
18%, 66.65% {
transform: translateY(0);
}
66.66%, 100% {
transform: translateY(10px);
}
}
@keyframes hlFill {
0%, 17.99% {
transform: scale(0);
}
18%, 51.32% {
transform: scale(1);
}
51.33%, 100% {
transform: scale(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hl {
gap: 8px;
}
.hl__pane--copy {
padding: 12px;
}
.hl__kicker {
font-size: 10px;
}
.hl__sub {
font-size: 10px;
}
.hl__dot {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.hl__slide, .hl__pane, .hl__title, .hl__fill {
animation: none !important;
transition: none !important;
}
.hl__slide.is-on .hl__pane {
clip-path: inset(0 0 0 0) !important;
}
.hl__title {
transform: none !important;
}
.hl__dot[aria-current=true] .hl__fill {
transform: scale(1) !important;
}
}(function () {
var root = document.querySelector('.hl');
var slides = [].slice.call(root.querySelectorAll('.hl__slide'));
var dots = [].slice.call(root.querySelectorAll('.hl__dot'));
var cur = 0;
// 점을 누르면 CSS 루프를 끄고 그 장으로 간다. 이때부터는 transition 위프가 맡는다.
// 직전 장은 is-prev 로 한 층 아래에 남겨 둔다 — 새 장이 덮는 동안 바닥이 비지 않게
function showSlide(i) {
root.classList.remove('is-demo');
var prev = cur;
cur = (i + slides.length) % slides.length;
slides.forEach(function (s, n) {
s.classList.toggle('is-prev', n === prev && n !== cur);
s.classList.toggle('is-on', n === cur);
});
dots.forEach(function (d, n) {
if (n === cur) { d.setAttribute('aria-current', 'true'); } else { d.removeAttribute('aria-current'); }
});
}
dots.forEach(function (d, i) {
d.addEventListener('click', function () { showSlide(i); });
// 좌우 화살표로도 옮겨 다닌다 — iframe 안에서 부모가 끌려가지 않게 preventScroll
d.addEventListener('keydown', function (e) {
var to = null;
if (e.key === 'ArrowRight') { to = i + 1; }
if (e.key === 'ArrowLeft') { to = i - 1; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = dots.length - 1; }
if (to === null) { return; }
e.preventDefault();
showSlide(to);
dots[cur].focus({ preventScroll: true });
});
});
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroSplitSlider.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type Slide = {
kicker: string;
title: string;
sub: string;
/** 점을 읽어 줄 이름 */
dotLabel: string;
};
export type HeroSplitSliderProps = {
slides?: Slide[];
label?: string;
dotsLabel?: string;
/** 세 장이 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
/** 강조색 — 그림 면의 아치·고리 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Delay = CSSProperties & { "--d"?: string };
const SparkleIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2" />
<path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z" />
</svg>
);
const DEFAULT_SLIDES: Slide[] = [
{ kicker: "봄 갈피", title: "가벼운 겉옷 한 벌", sub: "아침저녁 바람막이로", dotLabel: "첫째 장으로" },
{ kicker: "여름 갈피", title: "그늘까지 챙기는 모자", sub: "접어서 가방에 넣습니다", dotLabel: "둘째 장으로" },
{ kicker: "가을 갈피", title: "두 겹으로 짠 담요", sub: "무릎 위에 딱 맞는 크기", dotLabel: "셋째 장으로" },
];
/* 시차는 --d 하나로 준다 — 슬라이드에 걸면 그 안의 면·제목이 물려받는다 */
const DELAYS = ["-.6667s", "0s", "-1.3333s"];
const ART = [styles.art1, styles.art2, styles.art3];
export default function HeroSplitSlider({
slides = DEFAULT_SLIDES,
label = "제품 소개 슬라이드",
dotsLabel = "슬라이드 고르기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#5b4bd6",
}: HeroSplitSliderProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [cur, setCur] = useState<number>(0);
const [prev, setPrev] = useState<number>(-1);
const dots = useRef<Array<HTMLButtonElement | null>>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 점을 누르면 CSS 루프를 끄고 그 장으로 간다. 직전 장은 한 층 아래에 남겨 둔다 */
const showSlide = (i: number): void => {
const to = (i + slides.length) % slides.length;
setLoop(false);
setPrev(cur === to ? -1 : cur);
setCur(to);
const el = dots.current ? dots.current[to] : null;
if (el) { el.focus({ preventScroll: true }); }
};
const onKey = (e: KeyEvt, i: number): void => {
let to = -1;
if (e.key === "ArrowRight") { to = i + 1; }
else if (e.key === "ArrowLeft") { to = i - 1 + slides.length; }
else if (e.key === "Home") { to = 0; }
else if (e.key === "End") { to = slides.length - 1; }
if (to < 0) { return; }
e.preventDefault();
showSlide(to);
};
/* is-on 은 루프 중에도 붙여 둔다 — reduced-motion 으로 애니메이션이 꺼져도 한 장은 보여야 한다 */
const slideClass = (i: number): string => {
const c = [styles.slide];
if (i === cur) { c.push(styles.isOn); }
if (!loop && i === prev) { c.push(styles.isPrev); }
return c.join(" ");
};
return (
<section
className={loop ? `${styles.hl} ${styles.isDemo}` : styles.hl}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
>
{/* 세 장이 같은 격자 칸(grid-area: 1 / 1)에 포개진다 — 복제본 없이 z 순서와 위프만으로 순환한다 */}
<div className={styles.deck}>
{slides.map((s: Slide, i: number) => (
<article className={slideClass(i)} key={s.title} style={{ "--d": DELAYS[i % DELAYS.length] } as Delay}>
<div className={`${styles.pane} ${styles.copy}`}>
<p className={styles.kicker}><SparkleIcon />{s.kicker}</p>
<h2 className={styles.title}>{s.title}</h2>
<p className={styles.sub}>{s.sub}</p>
</div>
<div className={`${styles.pane} ${styles.art} ${ART[i % ART.length]}`} aria-hidden="true">
<span className={`${styles.shape} ${styles.s1}`} />
<span className={`${styles.shape} ${styles.s2}`} />
</div>
</article>
))}
</div>
<div className={styles.dots} role="group" aria-label={dotsLabel}>
{slides.map((s: Slide, i: number) => (
<button
className={styles.dot}
type="button"
key={s.dotLabel}
style={{ "--d": DELAYS[i % DELAYS.length] } as Delay}
aria-label={s.dotLabel}
aria-current={!loop && i === cur ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { if (dots.current) { dots.current[i] = el; } }}
onClick={() => showSlide(i)}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.fill} />
</button>
))}
</div>
</section>
);
}08 · Split slider hero — Measured area 17.644% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
With 15 animations it is the busiest build of the nine at 17.644% over 17 moving frames, and the dots are labeled buttons carrying aria-current.
.hl__slide {
grid-area: 1 / 1;
z-index: 1;
display: grid;
grid-template-columns: 1fr 38%;
}
.hl__pane {
box-sizing: border-box;
transition: clip-path $dur-enter $easing;
}
.hl__pane--copy { clip-path: inset(0 100% 0 0); }
.hl__pane--art { clip-path: inset(0 0 0 100%); }
@keyframes hlFill {
0%, 17.99% { transform: scale(0); }
18%, 51.32% { transform: scale(1); }
51.33%, 100% { transform: scale(0); }
}
09Minimal logo hero
What moves
Only a 56px badge, one line of copy, a status note, and a single button remain, while two rings ripple out from the mark half a cycle apart (the second at -1s), spreading to 1.9 times their size before they fade. The ring border is 3px rather than 2px because the thinner stroke all but vanished in the 480×300 grid thumbnail, and the loop finishes its return inside 88% so the last sampled frame never catches the reset.

<main class="stage">
<section class="hm is-demo" aria-label="Coming soon notice">
<div class="hm__mark">
<!-- 고리 두 겹이 시차를 두고 퍼진다. 주인공(로고 표시)은 루프 내내 제자리에 남는다 -->
<span class="hm__ring hm__ring--a" aria-hidden="true"></span>
<span class="hm__ring hm__ring--b" aria-hidden="true"></span>
<span class="hm__badge">
<svg viewBox="0 0 256 256" width="30" height="30" aria-hidden="true" focusable="false" fill="currentColor"><path d="M128,112a36,36,0,1,1,36-36A36,36,0,0,1,128,112Zm60,24a36,36,0,1,0,36,36A36,36,0,0,0,188,136ZM68,136a36,36,0,1,0,36,36A36,36,0,0,0,68,136Z" opacity="0.2"/><path d="M172,76a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,76Zm-44,28a28,28,0,1,1,28-28A28,28,0,0,1,128,104Zm60,24a44,44,0,1,0,44,44A44.05,44.05,0,0,0,188,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,188,200ZM68,128a44,44,0,1,0,44,44A44.05,44.05,0,0,0,68,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,68,200Z"/></svg>
</span>
</div>
<p class="hm__copy">Opening soon</p>
<button class="hm__btn" type="button" aria-pressed="false">Tell me when it opens</button>
<p class="hm__live" role="status" aria-live="polite">Kept on this screen only, no email</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 무대
$hr-paper: $stage-paper; // 배지 아이콘 on 바이올렛 6.14:1
$hr-mist: #b6b0c0; // 잉크 위 보조 글자 8.65:1
$hr-yellow: $stage-yellow; // 버튼 면 (잉크 글자 12.63:1)
$hr-violet: #9791c9; // 로고 배지 면
$hr-lilac: #d8d6eb; // 잉크 위 강조 글자·고리 6.99:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 고리 한 겹이 퍼졌다 사라지는 한 바퀴
$easing: $ease-out; // 퍼질수록 느려진다
$color: $hr-lilac; // 고리 색
@include stage($hr-ink);
.hm {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(360px, 90vw);
text-align: center;
}
.hm__mark {
position: relative;
display: grid;
place-items: center;
width: 72px;
height: 72px;
}
// 정적 상태 — 루프가 없어도 고리 두 겹이 그대로 보인다(.is-demo 를 뗀 화면이 비면 안 된다)
.hm__ring {
position: absolute;
inset: 0;
border: 3px solid $color; // 2px 는 480×300 축소본(격자 카드)에서 고리가 거의 안 보였다
border-radius: 50%;
scale: 1;
opacity: .34;
}
.hm__ring--b { scale: 1.34; opacity: .18; }
.hm__badge {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: $r-card;
background: $hr-violet;
color: $hr-paper;
}
.hm__copy {
margin: 0;
color: $hr-mist;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
}
.hm__btn {
margin: 0;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-pill;
background: $hr-yellow;
color: $hr-ink;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: transform $dur-quick $ease-spring;
}
.hm__btn:hover { transform: translateY(-2px); }
.hm__btn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.hm__live {
margin: 0;
color: $color;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 고리는 scale 로 퍼지며 opacity 로 사라진다. 두 겹이 한 바퀴의 절반씩 어긋난다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.hm.is-demo .hm__ring {
animation-name: hmRing;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__ring--b { animation-delay: -1s; }
// 한 바퀴를 88% 안에 끝낸다 — 24프레임의 마지막 장이 95.8% 지점이라 그 뒤는 찍히지 않는다.
// 되돌아오는 구간(88%→100%)은 opacity 0 이라 눈에 안 띈다
@keyframes hmRing {
0% { scale: .58; opacity: .62; }
88% { scale: 1.9; opacity: 0; }
88.01%, 100% { scale: .58; opacity: 0; }
}
// 좁은 화면 — 글자와 여백만 줄인다. 값을 올리지 않는다.
// 고리는 scale 로 커진 만큼 경계 상자도 커져 scrollHeight 에 잡힌다(219 실측) — 표시 자체를 줄인다
@media (max-width: 340px), (max-height: 220px) {
.hm { gap: $sp-2; }
.hm__mark { width: 56px; height: 56px; }
.hm__badge { width: 44px; height: 44px; }
.hm__badge svg { width: 24px; height: 24px; }
.hm__btn { padding: $sp-2 $sp-4; font-size: 12px; }
.hm__live { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .hm.is-demo .hm__ring (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 고리·글자·버튼은 그대로 남긴다
.hm__ring { animation: none !important; }
.hm__ring--a { scale: 1 !important; opacity: .34 !important; }
.hm__ring--b { scale: 1.34 !important; opacity: .18 !important; }
.hm__btn { 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);
}
.hm {
display: grid;
justify-items: center;
gap: 12px;
width: min(360px, 90vw);
text-align: center;
}
.hm__mark {
position: relative;
display: grid;
place-items: center;
width: 72px;
height: 72px;
}
.hm__ring {
position: absolute;
inset: 0;
border: 3px solid #d8d6eb;
border-radius: 50%;
scale: 1;
opacity: 0.34;
}
.hm__ring--b {
scale: 1.34;
opacity: 0.18;
}
.hm__badge {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: #9791c9;
color: #ffffff;
}
.hm__copy {
margin: 0;
color: #b6b0c0;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
}
.hm__btn {
margin: 0;
padding: 8px 20px;
border: 0;
border-radius: 999px;
background: #ffd23f;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hm__btn:hover {
transform: translateY(-2px);
}
.hm__btn:focus-visible {
outline: 2px solid #d8d6eb;
outline-offset: 3px;
}
.hm__live {
margin: 0;
color: #d8d6eb;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.hm.is-demo .hm__ring {
animation-name: hmRing;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__ring--b {
animation-delay: -1s;
}
@keyframes hmRing {
0% {
scale: 0.58;
opacity: 0.62;
}
88% {
scale: 1.9;
opacity: 0;
}
88.01%, 100% {
scale: 0.58;
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.hm {
gap: 8px;
}
.hm__mark {
width: 56px;
height: 56px;
}
.hm__badge {
width: 44px;
height: 44px;
}
.hm__badge svg {
width: 24px;
height: 24px;
}
.hm__btn {
padding: 8px 16px;
font-size: 12px;
}
.hm__live {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.hm__ring {
animation: none !important;
}
.hm__ring--a {
scale: 1 !important;
opacity: 0.34 !important;
}
.hm__ring--b {
scale: 1.34 !important;
opacity: 0.18 !important;
}
.hm__btn {
transition: none !important;
}
}(function () {
var root = document.querySelector('.hm');
var btn = root.querySelector('.hm__btn');
var live = root.querySelector('.hm__live');
var waiting = false;
// 버튼을 누르면 aria-live 한 줄이 바뀐다 (실제 등록이 아니라 이 화면 안의 표시)
btn.addEventListener('click', function () {
waiting = !waiting;
live.textContent = waiting ? 'Marked as waiting for the notice' : 'Kept on this screen only, no email';
btn.textContent = waiting ? 'Cancel the notice' : 'Tell me when it opens';
btn.setAttribute('aria-pressed', waiting ? 'true' : 'false');
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroMinimalLogoPulse.module.scss";
export type HeroMinimalLogoPulseProps = {
label?: string;
copy?: string;
cta?: string;
ctaOn?: string;
note?: string;
noteOn?: string;
/** 고리 한 겹이 퍼졌다 사라지는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 고리·안내 줄의 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CirclesThreeIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="30" height="30" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M128,112a36,36,0,1,1,36-36A36,36,0,0,1,128,112Zm60,24a36,36,0,1,0,36,36A36,36,0,0,0,188,136ZM68,136a36,36,0,1,0,36,36A36,36,0,0,0,68,136Z" opacity="0.2" />
<path d="M172,76a44,44,0,1,0-44,44A44.05,44.05,0,0,0,172,76Zm-44,28a28,28,0,1,1,28-28A28,28,0,0,1,128,104Zm60,24a44,44,0,1,0,44,44A44.05,44.05,0,0,0,188,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,188,200ZM68,128a44,44,0,1,0,44,44A44.05,44.05,0,0,0,68,128Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,68,200Z" />
</svg>
);
export default function HeroMinimalLogoPulse({
label = "준비 중 안내",
copy = "곧 문을 엽니다",
cta = "열리면 알려 주세요",
ctaOn = "알림 받기 취소",
note = "이메일 없이 이 화면에서만 받습니다",
noteOn = "알림 대기자로 표시했습니다",
duration = 2000,
easing = "ease-out",
color = "#9d93ff",
}: HeroMinimalLogoPulseProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [on, setOn] = useState<boolean>(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.hm} ${styles.isDemo}` : styles.hm}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.mark}>
{/* 고리 두 겹이 시차를 두고 퍼진다. 주인공(로고 표시)은 루프 내내 제자리에 남는다 */}
<span className={`${styles.ring} ${styles.ringA}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ringB}`} aria-hidden="true" />
<span className={styles.badge}><CirclesThreeIcon /></span>
</div>
<p className={styles.copy}>{copy}</p>
<button
className={styles.btn}
type="button"
aria-pressed={on ? "true" : "false"}
onClick={() => setOn(!on)}
>
{on ? ctaOn : cta}
</button>
{/* 버튼을 누르면 이 한 줄이 바뀐다 — 실제 등록이 아니라 이 화면 안의 표시 */}
<p className={styles.live} role="status" aria-live="polite">{on ? noteOn : note}</p>
</section>
);
}09 · Minimal logo hero — Measured area 7.18% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Two animations are all it has, 7.483% cumulative area over 23 moving frames, the fewest moving parts of the set; it is the teaser and coming-soon pattern.
.hm__ring {
position: absolute;
inset: 0;
border: 3px solid $color;
border-radius: 50%;
scale: 1;
opacity: .34;
}
.hm__ring--b { scale: 1.34; opacity: .18; }
@keyframes hmRing {
0% { scale: .58; opacity: .62; }
88% { scale: 1.9; opacity: 0; }
88.01%, 100% { scale: .58; opacity: 0; }
}
Where it breaks — the trap
The first wall was the scrollport itself. The fold in 07 was first wired to an anonymous scroll(nearest) timeline, and its progress never left 0, because an overflow: hidden ancestor qualifies as a scrollport and the nearest one never scrolled anywhere. Naming the timeline on the real scroller and pointing every animated piece at it by name fixed the fold outright, which is why the source names --hfscroll instead of trusting nearest. Sticky headroom taught the same lesson: padding-bottom on the scroller sits outside the sticky range and buys nothing, so the body card's own height is what gives the hero room to shrink. Two quieter bugs rode along, both caught in the browser: the dim layer covering the hero swallowed the button's clicks until it got pointer-events: none, and the card came out 40px too tall until box-sizing: border-box stopped its padding from adding to min-height.
The second was arithmetic on the dots in 08. Filling a dot at 0% moved the indicator while the outgoing slide was still wiping out, and the natural cut point, 33.33%, sits exactly on a z-order keyframe boundary where a 24-frame sample spaced 4.1667% apart can catch all three dots empty in one frame. The cuts moved to 18% and 51.33%, both of which fall between sample frames, and the all-empty frame stopped existing.
The third is a fight the highlight bar in 03 can never win. The poster frame is picked as the frame that changed most from the one before it, and a band .78em tall filling behind one word loses that contest to words arriving from 115% below .16s apart no matter how its keyframes are tuned, because during the entrance two words are moving at once and they simply cover more ground. The representative frame therefore shows the completed words, and the band's fill is something you watch in the gif.
The fourth was the static state. A counter strip left at translateY(0) shows its first digit, so the grid-card thumbnail and the reduced-motion rendering of 06 would have displayed the starting digits instead of the settled 98, 12, and 24; pinning the resting position to translateY($hc-cell * -6), the seventh cell, makes every state outside the loop read the final figures. The same discipline shows up in 04 in a milder form: the two bright blobs began near the top left where the subline sits and were draining its contrast, so both were pushed down out of the text area, one to the bottom-right corner and the other across the bottom of the right half, and the mist subline kept its 8.65:1. The three trust cells in 06 turn into a data problem once the numbers accumulate daily, and that is the side habit tracker UI takes with its grass grid. Every corrected value in this section is already baked into the archive that u6yhwkzq opens, so none of it has to be rediscovered the hard way.
Accessibility
Every pairing below was pushed through the WCAG 2.1 relative-luminance arithmetic before it was allowed into a demo, and three pairings were computed, failed, and then barred from use.
| Foreground | Background | Measured ratio | Body-text bar |
|---|---|---|---|
| White #ffffff | Ink #17141a | 18.24:1 | Pass |
| Yellow #ffd23f | Ink #17141a | 12.63:1 | Pass |
| Lilac #9d93ff | Ink #17141a | 6.99:1 | Pass |
| Mist #b6b0c0 | Ink #17141a | 8.65:1 | Pass |
| Ink #17141a | Cream #fdf7ec | 17.10:1 | Pass |
| Stone #56505e | Cream #fdf7ec | 7.28:1 | Pass |
| Violet #5b4bd6 | Cream #fdf7ec | 5.75:1 | Pass |
| White #ffffff | Violet #5b4bd6 | 6.14:1 | Pass |
| White #ffffff | Grass #0f7a52 | 5.35:1 | Pass |
| Ink #17141a | Orange #ff4d1f | 5.50:1 | Pass |
| White #ffffff | Orange #ff4d1f | 3.32:1 | Fails — never used |
| White #ffffff | Yellow #ffd23f | 1.44:1 | Fails — never used |
| Violet #5b4bd6 | Ink #17141a | 2.97:1 | Fails — never used |
That is why every word on the orange stages of 05 and 08 is ink, why the yellow stages of 02 and 06 carry ink text only, and why violet never appears as text on ink; lilac does that job instead at 6.99:1. One pairing sits in between: the violet focus ring on the yellow stage of 02 measures 3.28:1, which clears the 3:1 bar for non-text indicators but would fail as copy, so it is reserved for the ring. The 4.5:1 bar itself and the formula behind these numbers are set out in the W3C's contrast guidance.
With prefers-reduced-motion: reduce, each pattern freezes its loop and keeps its information. In 01 the cards and the cue are pinned to transform: none with the copy, buttons, and stacked art untouched. In 02 the sheen is hidden outright and the tiles park at rest, leaving the badge, labels, and both buttons. In 03 the words stop in place and the band is held at scaleX(1), so the key word keeps its highlight. In 04 the blobs and the beam stop dead while the glass card, title, subline, and pill CTA stay exactly as they were, and the pause switch keeps working. In 05 the zoom, pulses, bursts, and note cuts all stop with the notes locked to full opacity, so the captions remain readable. In 06 the strip and underline stop, and because the resting transform is the final digit, the settled values are what you see. In 07 the hero, dim, and card drop their scroll-driven animations, the hero keeps its rounded top corners, and the card sits at transform: none; Firefox, which still keeps scroll-driven animation behind a flag in its stable build, gets the same picture by fallback. In 08 the wipes and z-cuts stop with the current slide fully revealed and its dot filled through aria-current. In 09 the rings hold at 1x and 1.34x with opacity .34 and .18, so the mark still reads as present. Every one of those overrides needs !important, because the autoplay selectors sit at (0, 3, 0) specificity and an unadorned rule inside a media query cannot outvote them.
The markup underneath is meant to be audited, not just admired. Demos 01 through 07 contain exactly one h1 each, 08 titles its three slides as h2 elements, and 09 carries no heading at all, which suits a teaser. Every interactive element, from buttons to hotspots to dots to the pause switch, draws a visible :focus-visible ring; the figures in 06 expose their values through aria-label while the rolling digits stay hidden; the fold in 07 follows MDN's scroll-timeline-name reference and the glass in 04 follows MDN's backdrop-filter page. More first-screen layouts are collected in the hero category, and the about page documents how each demo here is measured before it ships.
FAQ
Do these hero section examples need JavaScript?
The motion does not. Every loop above is CSS animation, and the scroll-driven fold in 07 is CSS from end to end. Script appears only where a person acts: the replay buttons in 01, 03, and 06, the pause switch in 04, the hotspot toggles in 05, the slide buttons in 08, the arrow-key pair in 02, the jump button in 07, and the notify button in 09. Even with all script stripped out, each pattern still ships a complete static state, because the static rules are written first and the loop is layered over them. The small announcing pill in 02 has a standalone life too, collected in status badge design.
Which pattern fits a page with no images at all?
04 was built for exactly that case: the background is three radial gradients and the card is glass, so the hero makes zero image requests. Beyond it, 03 needs nothing but type, 06 nothing but numbers, and 09 nothing but a mark, which together cover a text-first service page, a stats-led company screen, and a teaser without touching a single asset.
How do I put one of these at the top of my own site?
Lift the pattern's root element out of the demo's .stage wrapper, make it the first element in the page, and let it span the full viewport width; then give it your own height, since the 300px you see here belongs to the preview canvas rather than to the pattern. The narrow-screen rules are written against max-width: 340px and max-height: 220px, so widen those breakpoints to your real phone size before you judge the small-screen result.









