01스플릿 제품 히어로
왼쪽에 문구와 버튼 두 개, 오른쪽에 제품 도형이 층층이 서고, 층이 아래에서부터 차례로 올라온 뒤 맨 아래 스크롤 안내 화살표가 계속 튑니다.
쓰임제품 소개 랜딩의 첫 화면
translateYanimation-fill-mode:focus-visible

<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 포함 · 히어로 섹션 디자인 패턴 9종 — JS 라이브러리 없이
이 데모가 속한 글
히어로 섹션 디자인 패턴 9종 — JS 라이브러리 없이히어로 섹션 디자인은 사이트를 열자마자 나타나는 첫 화면의 구도를 짜는 일입니다. 여기 아홉 가지는 스플릿·센터 스택·글래스 카드·슬라이더 같은 배치 패턴을 HTML 과 CSS 만으로 짜 둔 것이라, JS 라이브러리 이 글의 데모 전부 보기


