히어로 섹션 디자인 패턴 9종 — JS 라이브러리 없이
히어로 섹션 디자인은 사이트를 열자마자 나타나는 첫 화면의 구도를 짜는 일입니다. 여기 아홉 가지는 스플릿·센터 스택·글래스 카드·슬라이더 같은 배치 패턴을 HTML 과 CSS 만으로 짜 둔 것이라, 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스플릿 제품 히어로
- 02 센터 스택 배지 히어로
- 03 단어별 등장 헤드라인
- 04 그라디언트 글래스 히어로
- 05 제품 확대 호출점 히어로
- 06 통계 카운터 히어로
- 07 스크롤로 접히는 히어로
- 08 스플릿 슬라이더 히어로
- 09 미니멀 로고 히어로
아홉 개를 놓은 순서는 홈페이지 메인 배너 디자인 유행이 아니라 방문자가 첫 화면에서 겪는 순서입니다. 눈이 먼저 가는 좌우 갈림(01 스플릿)과 한가운데 쌓임(02 센터 스택)으로 시작해, 문구 한 줄로 승부하는 구도(03)까지가 글자가 주인공인 셋입니다. 다음 셋은 바닥과 소품 차례입니다 — 사진 한 장 없이 색 덩어리와 유리로 배경을 만들고(04), 제품을 확대하며 기능을 짚고(05), 숫자 세 칸으로 신뢰를 세웁니다(06). 마지막 셋은 첫 화면이 다음으로 넘어가는 방식입니다. 스크롤과 함께 접히며 본문으로 이어지고(07), 여러 제품을 같은 칸에서 번갈아 보여 주고(08), 로고 하나만 남겨 기다리게 합니다(09). 아홉 개는 잉크 #17141a · 크림 #fdf7ec · 바이올렛 #5b4bd6 · 옐로 #ffd23f 값을 함께 쓰기에 두세 개를 한 페이지에 섞어 놓아도 색이 싸우지 않습니다. 이웃 글 히어로 섹션 애니메이션 9종이 첫 화면 부품의 움직임을 다뤘다면 이번 편은 화면 구도, 곧 무엇을 어디에 놓을지가 주제입니다. 구도의 차례로만 보면 이 아홉 칸이 문을 열고 푸터 디자인 패턴이 문을 닫으며, 그 사이에 오는 증언의 자리는 고객 후기 섹션이 따로 맡습니다.
01스플릿 제품 히어로
히어로 섹션 ui 에서 무엇이 보이나
왼쪽에 머리말·제목·버튼 둘, 오른쪽에 제품 도형 세 장이 층으로 서고, 세 장이 0.12초 간격으로 아래에서 올라와 자리 잡은 뒤 맨 아래 스크롤 안내가 계속 튑니다. 층이 올라오는 거리는 --rise 변수 하나(22px)에 두었고 좁은 화면에서는 14px 로 줄어듭니다.

<main class="stage">
<section class="hs is-demo" aria-label="제품 소개 첫 화면">
<div class="hs__copy">
<span class="hs__eyebrow">새 버전 공개</span>
<h1 class="hs__title">한 화면에 모이는 팀의 하루</h1>
<p class="hs__lead">흩어진 일정과 메모를 한곳에서 봅니다.</p>
<div class="hs__actions">
<a class="hs__btn hs__btn--primary" href="#">무료로 시작하기</a>
<a class="hs__btn hs__btn--ghost" href="#">기능 둘러보기</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>아래로 보기</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 · 스플릿 제품 히어로 — 측정 누적면적 11.617% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
제품 소개 랜딩의 첫 화면에 씁니다.
// 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; }
@keyframes hsRise {
0% { transform: translateY(var(--rise)); }
18%, 60% { transform: translateY(0); }
88%, 100% { transform: translateY(var(--rise)); }
}
02센터 스택 배지 히어로
화면에서 일어나는 일
한가운데 한 축에 배지·제목·설명·버튼 쌍·신뢰 표시 넷이 쌓이는 구도입니다. 배지 위로 빛줄기가 한 번 지나가고(70% 지점까지), 신뢰 표시 넷이 0.14초 간격으로 7px 들렸다 앉습니다.

<main class="stage">
<section class="hb is-demo" aria-label="출시 안내 첫 화면">
<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">이번 주 업데이트</span>
</p>
<h1 class="hb__title">새 기능이 먼저 도착합니다</h1>
<p class="hb__lead">가입하면 공개 전에 미리 써 볼 수 있습니다.</p>
<div class="hb__actions">
<a class="hb__btn hb__btn--solid" href="#">지금 신청하기</a>
<a class="hb__btn hb__btn--line" href="#">변경 내역 보기</a>
</div>
<!-- 신뢰 표시 — 실제 로고가 아니라 둥근 사각형 안에 일반 아이콘 + 일반명사 라벨 -->
<ul class="hb__trust" aria-label="신뢰 표시">
<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">구성</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">연동</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">정확도</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">성장</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 · 센터 스택 배지 히어로 — 측정 누적면적 3.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
출시 안내나 가입 유도 랜딩의 첫 화면에 씁니다.
.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%);
}
// 빛줄기는 알약 밖(오른쪽)에서 멈춰 있다가 다음 바퀴에 왼쪽으로 되돌아간다 —
// overflow: hidden 이 닫고 있으므로 되감기가 화면에 보이지 않는다
@keyframes hbSheen {
0% { transform: translateX(-160%); }
70%, 100% { transform: translateX(320%); }
}
03단어별 등장 헤드라인
어떤 움직임인가
제목이 통째로 나타나지 않고 낱말 하나씩, 각 낱말의 상자 안에서 아래로부터 올라옵니다. 가장 중요한 낱말 뒤에서는 형광 띠가 왼쪽부터 차올라 그 낱말 뒤를 채우는데, 띠의 기본값은 다 찬 상태라 애니메이션이 꺼진 화면에서도 강조가 남습니다.

<main class="stage">
<section class="hk is-demo" aria-label="브랜드 첫 화면">
<!-- 낱말마다 span — 각 낱말이 제 상자 안에서 아래로부터 올라온다 -->
<h1 class="hk__title">
<span class="hk__word hk__word--1"><span class="hk__wi">가볍게</span></span>
<span class="hk__word hk__word--2"><span class="hk__wi">빠르게</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">확실하게</span></span>
</span>
</h1>
<p class="hk__lead">세 낱말로 끝나는 브랜드 한 줄.</p>
<div class="hk__actions">
<a class="hk__btn" href="#">
<span>브랜드 보러 가기</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>다시 보기</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'); }
// "다시 보기" 는 등장만 한 번 되풀이한다.
// 클래스를 뗐다 붙이는 사이에 리플로를 한 번 일으켜야 애니메이션이 다시 걸린다
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 · 단어별 등장 헤드라인 — 측정 누적면적 3.274% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
한 문장으로 승부하는 브랜드 첫 화면에 씁니다.
// 낱말 하나가 제 상자 안에서 올라온다 — 상자가 넘치는 부분을 잘라 줘야
// "아래에서 솟는" 것으로 보인다. 아랫여백은 받침이 잘리지 않게 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; }
// 띠는 낱말이 다 선 뒤(0.55s)에 차올랐다가 낱말이 내려가기 전에 물러난다
@keyframes hkBand {
0% { transform: scaleX(0); }
12%, 50% { transform: scaleX(1); }
60%, 100% { transform: scaleX(0); }
}
04그라디언트 글래스 히어로
무엇이 보이나
사진 한 장 없이 색 덩어리 세 개가 서로 다른 위상으로 느리게 흐르는 바닥 위에 반투명 유리 카드가 떠 있고, 빛이 카드 표면을 대각선으로 한 번 쓸고 지나갑니다. 덩어리는 blur 필터가 아니라 radial-gradient 라 가장자리가 이미 부드럽고, 배경이 화면 전체를 움직인 덕에 누적면적이 34.261% 로 아홉 중 가장 넓습니다.

<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">사진 없이도 첫 화면이 빛난다</h1>
<p class="hg__sub">색 덩어리 셋과 유리 한 장이면 배경 이미지가 필요 없습니다.</p>
<a class="hg__cta" href="#">무료로 시작하기
<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">빛 흐름 멈춤</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 · 그라디언트 글래스 히어로 — 측정 누적면적 34.29% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
이미지 소재가 없는 서비스 소개 첫 화면에 씁니다.
.hg__blob--a {
left: -64px;
top: -104px;
background: radial-gradient(circle at 50% 50%, $color, rgba($color, 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%);
}
05제품 확대 호출점 히어로
화면에서 일어나는 일
제품 그림이 루프 내내 천천히 커졌다 제자리로 돌아오는 동안(52% 지점에 scale 1.12) 호출점 둘이 부풀었다 터지고, 터진 자리에 설명 쪽지가 한 프레임에 딱 바뀌어 나타납니다. 쪽지를 반투명하게 띄우지 않고 steps(1, end) 컷으로 잘랐기에 유령 장이 없습니다.

<main class="stage">
<section class="hz is-demo">
<div class="hz__copy">
<h1 class="hz__title">기능은 짚어서 보여 준다</h1>
<p class="hz__sub">점을 누르면 그 자리에 설명 쪽지가 뜹니다.</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="지표 설명 열기">
<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">지표를 한눈에</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="알림 설명 열기">
<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">알림만 골라서</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 · 제품 확대 호출점 히어로 — 측정 누적면적 10.767% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
기능을 짚어 보여 주는 제품 상세 첫 화면에 씁니다.
// 쪽지는 반투명 유령을 만들지 않는다 — 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;
}
@keyframes hzZoom {
0% { transform: scale(1); }
52% { transform: scale(1.12); }
88%, 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; }
}
06통계 카운터 히어로
어떤 움직임인가
숫자 세 칸이 슬롯머신처럼 자릿수를 굴러 올리고, 굴림이 멈추면 각 숫자 아래 밑줄이 왼쪽부터 차오릅니다. 스트립은 steps(6, end) 로 굴러 매 순간 온전한 숫자만 보이고 tabular-nums 로 자릿수 폭이 흔들리지 않습니다.

<main class="stage">
<section class="hc is-demo">
<h1 class="hc__title">숫자가 먼저 말하는 회사 소개</h1>
<div class="hc__row">
<div class="hc__cell hc__cell--1">
<!-- 자릿수 스트립: 세로로 쌓은 숫자 열을 창 안에서 굴려 올린다. 읽히는 값은 aria-label 하나 -->
<p class="hc__value" role="img" aria-label="98퍼센트">
<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">가동 안정성</p>
</div>
<div class="hc__cell hc__cell--2">
<p class="hc__value" role="img" aria-label="12배">
<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">배포 속도</p>
</div>
<div class="hc__cell hc__cell--3">
<p class="hc__value" role="img" aria-label="24시간">
<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">평균 응답</p>
</div>
</div>
<button class="hc__again" type="button">다시 세기</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 · 통계 카운터 히어로 — 측정 누적면적 2.145% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
실적·규모를 앞세우는 회사 소개 첫 화면에 씁니다.
.hc__win {
display: block;
overflow: hidden;
height: $hc-cell;
}
.hc__strip {
display: block;
transform: translateY($hc-cell * -6); // 일곱 칸 중 마지막 = 확정된 숫자
}
.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); }
}
07스크롤로 접히는 히어로
무엇이 보이나
스크롤을 내리면 히어로가 제자리에 붙어 있는 채로 작아지고(scale .86) 모서리가 둥글어지면서 본문 카드가 그 위로 올라와 덮습니다. 스크롤 상자에 이름 붙은 타임라인을 지정하고 키프레임을 animation-timeline 으로 연결했기에 스크롤 양과 화면이 1:1 로 맞습니다.

<main class="stage">
<div class="hf">
<!-- 자체 스크롤 상자. 이 상자가 스크롤포트이자 타임라인(--hfscroll)의 주인이다.
html·body 는 절대 스크롤되지 않는다 -->
<div class="hf__scroller" tabindex="0" role="region" aria-label="첫 화면 스크롤 상자">
<section class="hf__hero">
<div class="hf__inner">
<h1 class="hf__title">스크롤 한 번으로 본문까지</h1>
<p class="hf__sub">첫 화면은 제자리에 붙어 접힙니다</p>
<button class="hf__btn" type="button">
본문으로 이동
<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">본문 시작</h2>
<p class="hf__p">접힌 첫 화면 위로 이 카드가 올라와 덮습니다.</p>
<p class="hf__p hf__p--soft">방향키로도 같은 자리를 지납니다.</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 · 스크롤로 접히는 히어로 — 측정 누적면적 19.888% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
첫 화면에서 본문으로 넘어가는 이음새에 씁니다.
// ⚠ 타임라인에 **이름**을 붙인다. `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; }
}
08스플릿 슬라이더 히어로
화면에서 일어나는 일
슬라이드 세 장이 같은 격자 칸에 포개져 있고, 왼쪽 글자 면은 왼쪽에서·오른쪽 그림 면은 오른쪽에서 서로 반대 방향으로 닦이듯 드러나 다음 장으로 넘어갑니다. z 순서는 steps(1, end) 컷으로만 바뀌고 위프는 이징 애니메이션이 맡아, 교차 구간에 반투명 유령이 생기지 않습니다.

<main class="stage">
<section class="hl is-demo" aria-label="제품 소개 슬라이드">
<!-- 세 장이 같은 격자 칸(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>
봄 갈피
</p>
<h2 class="hl__title">가벼운 겉옷 한 벌</h2>
<p class="hl__sub">아침저녁 바람막이로</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>
여름 갈피
</p>
<h2 class="hl__title">그늘까지 챙기는 모자</h2>
<p class="hl__sub">접어서 가방에 넣습니다</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>
가을 갈피
</p>
<h2 class="hl__title">두 겹으로 짠 담요</h2>
<p class="hl__sub">무릎 위에 딱 맞는 크기</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="슬라이드 고르기">
<button class="hl__dot hl__dot--1" type="button" aria-current="true" aria-label="첫째 장으로"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--2" type="button" aria-label="둘째 장으로"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--3" type="button" aria-label="셋째 장으로"><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 · 스플릿 슬라이더 히어로 — 측정 누적면적 17.644% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
여러 제품·시즌을 번갈아 보여 주는 첫 화면에 씁니다.
.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%); }
// 맨 위(3) → 바로 아래(2) → 바닥(1). 바닥일 때만 숨은 상태로 되돌린다 — 위 두 장이 덮고 있어 안 보인다
@keyframes hlZ {
0%, 33.32% { z-index: 3; }
33.33%, 66.65% { z-index: 2; }
66.66%, 100% { z-index: 1; }
}
09미니멀 로고 히어로
어떤 움직임인가
가운데 로고 표시와 문구 한 줄, 버튼 하나, 그 아래 안내 한 줄만 남기는 구도입니다. 로고 둘레에서 고리 두 겹이 반 바퀴 어긋난 위상으로 퍼졌다 사라지며, 퍼짐은 88% 안에 끝나 되돌아오는 구간이 눈에 띄지 않습니다.

<main class="stage">
<section class="hm is-demo" aria-label="준비 중 안내">
<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">곧 문을 엽니다</p>
<button class="hm__btn" type="button" aria-pressed="false">열리면 알려 주세요</button>
<p class="hm__live" role="status" aria-live="polite">이메일 없이 이 화면에서만 받습니다</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 ? '알림 대기자로 표시했습니다' : '이메일 없이 이 화면에서만 받습니다';
btn.textContent = waiting ? '알림 받기 취소' : '열리면 알려 주세요';
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 · 미니멀 로고 히어로 — 측정 누적면적 7.18% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
브랜드 티저·준비 중 안내 페이지에 씁니다.
// 정적 상태 — 루프가 없어도 고리 두 겹이 그대로 보인다(.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; }
@keyframes hmRing {
0% { scale: .58; opacity: .62; }
88% { scale: 1.9; opacity: 0; }
88.01%, 100% { scale: .58; opacity: 0; }
}
어디서 깨지는가 — 함정
처음 무너진 것은 07 의 스크롤 연결이었습니다. 가까운 스크롤 상자를 스스로 고르는 scroll(nearest) 방식을 먼저 썼는데, 내용을 자르기만 하는 overflow: hidden 상자도 브라우저는 스크롤포트로 집어들어, 진행률이 영원히 0 인 타임라인에 접힘 애니메이션이 묶였습니다. 스크롤을 아무리 내려도 히어로가 줄어들지 않는 화면이 되었습니다. 타임라인에 이름(scroll-timeline-name: --hfscroll)을 붙이고 히어로와 카드에서 animation-timeline 으로 그 이름을 명시해 연결하니 비로소 스크롤 양이 화면에 닿았습니다. 이징을 등속(linear)으로 둔 것도 같은 이유로, 스프링 곡선을 얹으면 손가락이 멈춘 뒤에도 화면이 홀로 흔들립니다. 키프레임은 한 방향으로만 쓰고 채움 모드를 both 로 묶어, 끝까지 내렸다가 다시 올려도 중간값에서 튀어 올라가지 않게 했습니다. 붙어 있는 히어로의 여유도 같은 자리에서 걸렸습니다 — 부모에 padding-bottom 을 줘 봐야 그 영역은 sticky 범위 밖이라 소용이 없어, 대신 덮어 올라올 본문 카드의 높이로 여유를 잡았습니다. 덧붙여 스크롤 구동 애니메이션은 크롬·엣지 115 와 사파리 26.0 부터 이미 돌고 파이어폭스만 아직 기본값이 꺼져 있어(2026-09-12 caniuse 기준 전역 87.22%), 지원이 없는 브라우저에서는 접힘 없이 히어로가 서 있고 카드가 위로 지나갑니다 — 정적 상태를 먼저 쓴 것이 이런 갈림길의 보험이었습니다.
두 번째는 08 의 점 인디케이터입니다. 점 채움을 장이 바뀌는 0% 지점에서 컷했더니, 새 장이 아직 닦이는 중인데 점만 먼저 옮겨 가 있었습니다. 경계를 슬라이드 교체 지점인 33.33% 에 맞추자 이번에는 세 점이 전부 빈 장이 한 프레임 잡혔습니다. 컷과 z 순서 키프레임의 경계가 같은 지점에 겹치면 그 프레임에서 두 상태가 딱 만나 버리는 것입니다. 컷을 18% 와 51.33% 로 옮겨 프레임 사이에 떨어뜨리니 점과 장이 같은 속도로 움직입니다 — 24프레임 캡처는 4.1667% 눈금이라 이 두 지점은 프레임에 걸치지 않습니다. 채움을 부드럽게 보간하면 반쯤 찬 점이 어중간하게 남기 마련이라, 채움도 컷으로 잘라 눈금 사이에 떨어뜨렸습니다.
세 번째는 대표 장면입니다. 썸네일로 쓰이는 한 장은 앞 장과 가장 크게 달라진 장으로 뽑히는데, 이 규칙은 작은 부품에게 불리합니다. 03 의 형광 띠는 85×15px 짜리 부품이라 프레임당 변화가 0.25% 인데, 낱말이 상자에서 솟는 등장은 프레임당 1.05~1.21% 를 움직입니다. 띠를 아무리 키워도 등장을 이길 수 없어, 대표 장면은 낱말 세 개가 다 선 그림으로 두고 띠는 움직임을 보여 주는 gif 에서만 보이게 했습니다. 키프레임 배분도 이에 맞춰 등장을 여러 프레임에 걸고 띠의 채움은 낱말이 다 선 뒤(0.55초)로 미뤄, 두 관심이 한 장에서 겹치게 했습니다. 같은 까닭에 05 의 쪽지도 컷 하나로만 세우지 않고 확대·터짐 같은 작은 움직임을 곁들였습니다 — 컷만 남으면 움직인 프레임 수가 모자라 캡처에서 죽은 그림처럼 보입니다.
마지막은 멈춘 화면입니다. 06 의 숫자 스트립을 굴림 애니메이션이 떼어진 상태에서 첫 칸에 서 있게 두면, 자동 재생이 꺼진 순간 확정값이 빈칸처럼 보입니다. 스트립의 기본 위치를 일곱 칸 중 마지막 칸, 곧 최종 숫자로 못 박아(translateY($hc-cell * -6)) 정적 상태에서도 값이 그대로 읽히게 했습니다. 04 에서는 밝은 색 덩어리 둘이 글자가 앉는 왼쪽 위를 침범해 미스트 보조 글자의 대비를 먹는 것을 보고, 둘을 아래로 내려(오른쪽 아래 모서리와 바닥 오른쪽 절반) 글자 자리의 바닥을 잔잔한 잉크로 지켰습니다. 미스트가 잉크 바닥 위에서 8.65:1 을 내려면 바닥이 잔잔해야 하므로, 움직이는 배경 위에서는 글자 주변을 지키는 일이 곧 대비 지키기입니다. 숫자 세 칸으로 신뢰를 세는 06 은 값이 매일 쌓이기 시작하면 관리 문제로 바뀌는데, 그 축은 습관 트래커 UI가 잔디 격자로 풀고 있습니다. 이렇게 깨진 자리를 고친 아홉 벌의 완성 코드는 압축 파일 안에 들어 있고, 열어 보는 데 필요한 비밀번호는 u6yhwkzq 입니다.
접근성
색은 짐작이 아니라 WCAG 2.1 상대휘도 공식으로 직접 계산했고, 통과한 조합만 무대에 올렸습니다. 맨 아래 세 줄이 이 팔레트에서 쓰지 않기로 한 조합입니다.
| 앞 | 뒤 | 실측 대비 | 본문 기준 |
|---|---|---|---|
| 흰 글자 #ffffff | 잉크 #17141a | 18.24:1 | 통과 |
| 옐로 #ffd23f | 잉크 #17141a | 12.63:1 | 통과 |
| 라일락 #9d93ff | 잉크 #17141a | 6.99:1 | 통과 |
| 미스트 #b6b0c0 | 잉크 #17141a | 8.65:1 | 통과 |
| 잉크 #17141a | 크림 #fdf7ec | 17.10:1 | 통과 |
| 스톤 #56505e | 크림 #fdf7ec | 7.28:1 | 통과 |
| 바이올렛 #5b4bd6 | 크림 #fdf7ec | 5.75:1 | 통과 |
| 흰 글자 #ffffff | 바이올렛 #5b4bd6 | 6.14:1 | 통과 |
| 흰 글자 #ffffff | 그래스 #0f7a52 | 5.35:1 | 통과 |
| 잉크 #17141a | 오렌지 #ff4d1f | 5.50:1 | 통과 |
| 흰 글자 #ffffff | 오렌지 #ff4d1f | 3.32:1 | 미달 — 쓰지 않는다 |
| 흰 글자 #ffffff | 옐로 #ffd23f | 1.44:1 | 미달 — 쓰지 않는다 |
| 바이올렛 #5b4bd6 | 잉크 #17141a | 2.97:1 | 미달 — 쓰지 않는다 |
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)에서는 아홉 개 모두 움직임만 끄고 상태와 정보를 남깁니다. 01 은 도형 스택과 스크롤 안내가 올라온 채로 서고, 02 는 빛줄기가 사라진 자리에 배지와 신뢰 표시가 그대로 앉아 있으며, 03 은 낱말이 다 선 상태에서 형광 띠만 가득 차 있게 둡니다. 04 는 덩어리와 빛줄기가 멈춰도 유리 카드와 글자가 남고, 05 는 확대와 터짐이 꺼진 대신 설명 쪽지 둘이 열린 채로 보입니다. 06 은 숫자가 최종값에 박힌 채 굴림이 멈추고 밑줄은 차 있는 상태로 서며, 07 은 접힘 없이 히어로가 서 있고 카드가 그 위를 지나갑니다. 08 은 지금 장이 온전히 보이고 누른 점의 aria-current 가 채움으로 남으며, 09 는 고리 두 겹이 정해진 크기와 옅기로 고정됩니다.
구조 쪽 장치는 이렇습니다. 데모마다 제목 단계를 하나로만 두었고 읽는 순서도 글자가 먼저·그림이 나중이라 화면 낭독기가 문구부터 만납니다. 버튼은 아홉 개 모두 :focus-visible 로 포커스 링을 무대 위에서 보이는 색으로 따로 지정했고, 02 의 버튼 쌍과 08 의 점 셋은 화살표 키로 옮겨 다닐 수 있습니다. 05 의 호출점은 aria-expanded 와 aria-controls 로 쪽지의 열림 상태를 알리고, 06 의 숫자는 굴러가는 스트립 전체를 aria-hidden 으로 감싼 뒤 값을 aria-label 한 줄로 읽히게 했습니다. 07 의 스크롤 상자는 tabindex 와 role="region" 으로 키보드로도 접근하게 두었고, 09 의 안내 문장은 role="status" 로 눌렀을 때 한 번만 읽힙니다. 접는 방식의 근거는 MDN 의 scroll-timeline-name 문서와 backdrop-filter 문서, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 첫 화면 소재를 더 둘러보려면 히어로 모음으로, 이 사이트가 코드를 어떻게 검증하는지는 소개에 적어 두었습니다.
FAQ
메인 배너 디자인의 높이는 화면에 꽉 채워야 하나요?
꽉 채우는 것이 답이 아닐 때가 많습니다. 휴대폰 브라우저는 주소창 높이가 들쭉날쭉해 100vh 고정 화면은 아래가 잘리거나 위가 뜨고, 첫 화면의 임무는 전부 보여 주는 것이 아니라 다음 동작으로 데려가는 것입니다. 이 아홉 개는 내용 높이에 맞추고(데모 무대는 480×300), 본문과의 이음새가 걱정되면 07 처럼 스크롤로 접히는 구도로 잇습니다. 02 의 알림 알약처럼 상태를 알리는 작은 표지는 태그·배지 UI에 단독 부품으로 모여 있습니다.
이 편과 히어로 애니메이션 9종 편은 어떻게 나눠서 쓰나요?
애니메이션 편이 첫 화면 부품의 움직임 효과를 모았다면 이번 편은 화면 전체의 구도를 모았습니다. 실제로는 짝입니다 — 예컨대 03 의 낱말 등장 구도에 그 글의 낱말 이징을 얹고, 07 의 접힘에 스크롤 효과를 더할 수 있습니다. 구도를 먼저 고르고 애니메이션 9종에서 움직임을 골라 얹는 순서를 권합니다.
사진 소재가 없는데 첫 화면을 채울 수 있나요?
됩니다. 04 는 사진 한 장 없이 radial-gradient 색 덩어리 세 개와 유리 카드 하나로 배경을 만들고, 05 는 도형 조합으로 제품 그림을 흉내 냈습니다. 외부 요청이 0건이라 로딩도 가볍고 색만 바꾸면 브랜드에 맞춰집니다. 첫 화면의 역할은 관심을 얻는 것이지 사진 감상이 아니므로 도형과 색으로도 충분히 설 수 있습니다.









