Block
Hero section
A full-screen hero in centered, split, and minimal variants with animated visuals.
- #arc
- #block
- #hero
- #landing
- #marketing
- #animation
- #section
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/hero-section.jsonFiles
components/arc/blocks/hero-section/hero-cadence.module.csscomponents/arc/blocks/hero-section/hero-cadence.tsxcomponents/arc/blocks/hero-section/hero-content.module.csscomponents/arc/blocks/hero-section/hero-content.tsxcomponents/arc/blocks/hero-section/hero-lumen-data.tscomponents/arc/blocks/hero-section/hero-lumen.module.csscomponents/arc/blocks/hero-section/hero-lumen.tsxcomponents/arc/blocks/hero-section/hero-mesh.module.csscomponents/arc/blocks/hero-section/hero-mesh.tsxcomponents/arc/blocks/hero-section/hero-motion.tscomponents/arc/blocks/hero-section/hero-relay.module.csscomponents/arc/blocks/hero-section/hero-relay.tsxcomponents/arc/blocks/hero-section/hero-section.module.csscomponents/arc/blocks/hero-section/hero-section.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdownhero-cadence.module.csshero-cadence.module.csshero-cadence.module.csscss
/* Minimal: the mesh is the whole screen, and type does the rest. The copy sits in the optical centre; customers hold the bottom edge. */
.hero { isolation: isolate; display: grid; }
.mesh {
--mesh-base: #f2ede7;
--mesh-1: #bdd3ec; --mesh-2: #c3c6ff; --mesh-3: #ffd0b0; --mesh-4: #f6bfd6; --mesh-5: #cfe8dc; --mesh-6: #fff3dc;
z-index: -1;
}
:global(:root[data-theme="dark"]) .mesh {
--mesh-base: #09090f;
--mesh-1: #0d3a47; --mesh-2: #2a2f86; --mesh-3: #5a2a3c; --mesh-4: #3a1f5a; --mesh-5: #7a3a22; --mesh-6: #12142c;
}
/* A theme-aware scrim keeps body text above 4.5:1 wherever the mesh drifts. */
.hero::after { content: ""; position: absolute; z-index: -1; inset: 0; background: linear-gradient(to bottom, transparent 40%, color-mix(in oklab, var(--background) 22%, transparent)); pointer-events: none; }
.inner { display: grid; box-sizing: border-box; width: 100%; max-width: 1320px; grid-template-rows: 1fr auto auto 1fr auto; gap: var(--space-10); margin: 0 auto; padding: clamp(88px, 12svh, 128px) var(--space-10) var(--space-10); }
.title { grid-row: 2; }
.row { grid-row: 3; }
.brands { grid-row: 5; }
.title { max-width: 14ch; font-size: clamp(2.75rem, 1rem + 7cqi, 7rem); }
.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--space-8) var(--space-16); }
.description { max-width: 520px; color: color-mix(in oklab, var(--foreground) 76%, transparent); }
.cta { display: grid; justify-items: end; gap: var(--space-3); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.fine { margin: 0; color: color-mix(in oklab, var(--foreground) 62%, transparent); font-size: var(--text-sm); }
.brands { display: grid; gap: var(--space-5); border-top: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent); padding-top: var(--space-6); }
.brands p { margin: 0; color: color-mix(in oklab, var(--foreground) 62%, transparent); font-size: var(--text-sm); }
.brands ul { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-5) var(--space-8); margin: 0; padding: 0; list-style: none; }
.brands li { display: inline-flex; align-items: center; gap: 9px; color: color-mix(in oklab, var(--foreground) 78%, transparent); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); }
.brands i { width: 22px; height: 22px; background: currentColor; -webkit-mask-position: center; mask-position: center; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: contain; mask-size: contain; }
@container hero (max-width: 800px) {
.row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.cta { justify-items: start; }
.brands ul { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@container hero (max-width: 560px) {
.inner { gap: var(--space-6); padding: 80px var(--space-4) var(--space-6); }
.actions { width: 100%; }
.actions > * { flex: 1 1 auto; }
.cta { width: 100%; }
.brands ul { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4) var(--space-3); }
.brands li:nth-child(n + 7) { display: none; }
.brands li { font-size: var(--text-base); }
.brands i { width: 18px; height: 18px; }
}
hero-cadence.tsxhero-cadence.tsxhero-cadence.tsxtsx
"use client";
import { MotionConfig, motion } from "motion/react";
import { ActionLink } from "./hero-content";
import type { HeroAction } from "./hero-content";
import { HeroMesh } from "./hero-mesh";
import type { HeroMeshPoint } from "./hero-mesh";
import { heroGroup, heroRise } from "./hero-motion";
import shell from "./hero-section.module.css";
import styles from "./hero-cadence.module.css";
export interface HeroCadenceProps {
primaryAction?: HeroAction;
secondaryAction?: HeroAction;
animateIn?: boolean;
className?: string;
}
/** Six points, authored the way the Gradient mesh editor saves them. Colors live in CSS, one palette per theme. */
const MESH: HeroMeshPoint[] = [
{ x: .1, y: .88, spread: .72 },
{ x: .86, y: .12, spread: .66 },
{ x: .16, y: .14, spread: .56 },
{ x: .62, y: .62, spread: .5 },
{ x: .94, y: .92, spread: .48 },
{ x: .44, y: .3, spread: .34 },
];
/** Sample customers. The marks are the brands' own icons, drawn in the text color. */
const BRANDS = [
{ name: "Linear", logo: "/block-logos/linear.svg" },
{ name: "Vercel", logo: "/block-logos/vercel.svg" },
{ name: "Raycast", logo: "/block-logos/raycast.svg" },
{ name: "Notion", logo: "/block-logos/notion.svg" },
{ name: "Figma", logo: "/block-logos/figma.svg" },
{ name: "Stripe", logo: "/block-logos/stripe.svg" },
{ name: "Loom", logo: "/block-logos/loom.svg" },
];
/** Minimal hero, one full screen: large type over a slowly drifting mesh gradient with grain, and the teams that use the product along the bottom edge. */
export function HeroCadence({ primaryAction = { label: "Mac용 받기", doneLabel: "다운로드를 시작했어요" }, secondaryAction = { label: "웹에서 써 보기", doneLabel: "케이던스를 여는 중이에요" }, animateIn = true, className }: HeroCadenceProps) {
const item = heroRise;
// Reduced motion: Motion skips every transform (rise, zoom, tilt entrance); opacity still fades briefly.
return <MotionConfig reducedMotion="user"><section className={[shell.hero, shell.screen, styles.hero, className].filter(Boolean).join(" ")}>
<HeroMesh className={styles.mesh} points={MESH} grain={.55} speed={.45} />
<motion.div className={styles.inner} variants={heroGroup} initial={animateIn ? "hidden" : false} animate="shown">
<motion.h1 variants={item} className={[shell.title, styles.title].join(" ")}>월요일이 오기 전에 이번 주 계획이 끝나요</motion.h1>
<div className={styles.row}>
<motion.p variants={item} className={[shell.description, styles.description].join(" ")}>케이던스가 할 일, 회의, 집중 목표를 읽고 중요한 일에 시간을 잡아 줘요. 계획이 바뀌면 일정도 알아서 옮겨 줘요.</motion.p>
<motion.div variants={item} className={styles.cta}>
<div className={styles.actions}>
<ActionLink action={primaryAction} kind="primary" />
<ActionLink action={secondaryAction} kind="secondary" />
</div>
<p className={styles.fine}>개인은 무료예요. 팀은 한 명당 월 ₩11,000부터예요.</p>
</motion.div>
</div>
<motion.div variants={item} className={styles.brands}>
<p>이런 회사의 팀들이 케이던스로 계획해요</p>
<ul aria-label="예시 고객사">
{BRANDS.map(brand => <li key={brand.name}><i style={{ maskImage: `url(${brand.logo})`, WebkitMaskImage: `url(${brand.logo})` }} aria-hidden="true" />{brand.name}</li>)}
</ul>
</motion.div>
</motion.div>
</section></MotionConfig>;
}
export default HeroCadence;
hero-content.module.csshero-content.module.csshero-content.module.csscss
.inner { display: grid; max-width: 1200px; margin: 0 auto; padding: var(--space-24) var(--space-10); }
.copy { display: grid; grid-template-columns: minmax(0, 1fr); justify-items: center; gap: var(--space-5); max-width: 760px; margin: 0 auto; text-align: center; }
.title { max-width: 16ch; }
.description { max-width: 560px; }
.announcement { display: inline-flex; max-width: 100%; min-height: 32px; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 4px var(--space-3); background: var(--surface); color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-align: left; text-decoration: none; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.arrow { flex: none; transition: transform var(--duration-fast) var(--ease-standard); }
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); margin-top: var(--space-3); }
.action { display: inline-flex; height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 var(--space-5); font: inherit; font-size: var(--text-sm); font-weight: 500; text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.action:active { transform: scale(.97); }
.primary { background: var(--accent); color: var(--accent-foreground); }
.secondary { border-color: var(--border); background: var(--surface); color: var(--foreground); }
@media (hover: hover) and (pointer: fine) {
.primary:hover { opacity: .9; }
.secondary:hover { background: var(--surface-muted); }
.announcement:hover { border-color: var(--border-strong); color: var(--foreground); }
:is(.action, .announcement):hover .arrow { transform: translateX(2px); }
}
.install { display: flex; width: min(100%, 440px); height: var(--control-height-md); align-items: center; gap: var(--space-2); margin-top: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-1) 0 var(--space-4); background: var(--surface); font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; text-align: left; }
.prompt { color: var(--text-muted); }
.command { flex: 1; overflow: hidden; color: var(--foreground); font: inherit; text-overflow: ellipsis; white-space: nowrap; }
.meta { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: var(--space-4) 0 0; border-top: 1px solid var(--border-subtle); padding: var(--space-5) 0 0; color: var(--text-muted); font-size: var(--text-sm); list-style: none; }
.media { min-width: 0; }
.split .inner { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); align-items: center; gap: var(--space-12); }
.split .copy, .minimal .copy { justify-items: start; margin: 0; text-align: left; }
.split .actions, .minimal .actions { justify-content: flex-start; }
.minimal .copy { max-width: 860px; }
@container hero (max-width: 800px) {
.split .inner { grid-template-columns: minmax(0, 1fr); gap: var(--space-10); }
}
@container hero (max-width: 560px) {
.inner { padding: var(--space-16) var(--space-4); }
.actions { width: 100%; }
.action { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) {
.action, .announcement, .arrow { transition: none; }
.action:active { transform: none; }
}
/* Buttons are Arc buttons; this only keeps them the height of the link actions beside them. */
.button { height: var(--control-height-md); min-height: var(--control-height-md); border-radius: var(--radius-control); font-size: var(--text-sm); }
hero-content.test.tshero-content.test.tshero-content.test.tsts
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HeroContent } from "./hero-content";
describe("HeroContent install command", () => {
const render = (install: Parameters<typeof HeroContent>[0]["install"]) => renderToStaticMarkup(createElement(HeroContent, { layout: "split", title: "Title", install, animateIn: false }));
it("names the copy button in Korean unless the page says otherwise", () => {
expect(render([{ label: "pnpm", command: "pnpm add x" }])).toContain("설치 명령어 복사하기");
});
it("names the copy button from the command, for a page in another language", () => {
const html = render([{ label: "pnpm", command: "pnpm add x", copyLabel: "Copy the install command", copiedLabel: "Copied", errorLabel: "Could not copy" }]);
expect(html).toContain("Copy the install command");
expect(html).not.toContain("설치 명령어 복사하기");
});
});
hero-content.tsxhero-content.tsxhero-content.tsxtsx
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { MotionConfig, motion } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons";
import { Button } from "../../button/button";
import { CopyButton } from "../../copy-button/copy-button";
import { heroGroup, heroRise } from "./hero-motion";
import shell from "./hero-section.module.css";
import styles from "./hero-content.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
export interface HeroAction {
label: string;
href?: string;
onClick?: () => void;
/** Opens in a new tab and shows an outward arrow. */
external?: boolean;
/** Shown in place of the label for a moment after a press, such as "체험을 시작했어요". Buttons only. */
doneLabel?: string;
}
export interface HeroInstallCommand {
/** Short label, such as npm or pnpm. The first command is shown. */
label: string;
command: string;
/** Accessible name of the copy button, and the messages after copying. Korean by default; set them for a page in another language. */
copyLabel?: string;
copiedLabel?: string;
errorLabel?: string;
}
export interface HeroContentProps {
layout: "centered" | "split" | "minimal";
announcement?: HeroAction | null;
title: string;
description?: string;
primaryAction?: HeroAction | null;
secondaryAction?: HeroAction | null;
install?: HeroInstallCommand[] | null;
media?: ReactNode;
meta?: string[];
animateIn?: boolean;
className?: string;
}
/** A link or button that looks like a Button. Links keep native navigation; a button with `doneLabel` confirms in place. */
export function ActionLink({ action, kind, className }: { action: HeroAction; kind: "primary" | "secondary"; className?: string }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const timer = window.setTimeout(() => setDone(false), 2400);
return () => window.clearTimeout(timer);
}, [done]);
const cls = [styles.action, styles[kind], className].filter(Boolean).join(" ");
const icon = action.external ? <HugeiconsIcon icon={ArrowUpRight01Icon} size={16} strokeWidth={1.75} aria-hidden="true" /> : kind === "primary" ? <UiIcon iconRole="arrow-right" className={styles.arrow} size={16} strokeWidth={1.75} aria-hidden="true" /> : null;
if (action.href) return <a className={cls} href={action.href} onClick={() => { playUiSound("click"); action.onClick?.(); }} {...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{action.label}{icon}</a>;
return <Button variant={kind} size="lg" className={[styles.button, className].filter(Boolean).join(" ")} onClick={() => { action.onClick?.(); if (action.doneLabel) setDone(true); }}>
{done ? <><UiIcon iconRole="check" size={16} strokeWidth={1.75} aria-hidden="true" />{action.doneLabel}</> : <>{action.label}{icon}</>}
</Button>;
}
/**
* The hero with your own content: announcement, headline, description, actions, an optional install line, and an
* optional visual beside the copy. Use it when none of the three designed variants fits the product.
*/
export function HeroContent({ layout, announcement, title, description, primaryAction, secondaryAction, install, media, meta, animateIn = true, className }: HeroContentProps) {
const item = heroRise;
const command = install?.[0];
// Reduced motion: Motion skips every transform (rise, zoom, tilt entrance); opacity still fades briefly.
return <MotionConfig reducedMotion="user"><section className={[shell.hero, styles.hero, styles[layout], className].filter(Boolean).join(" ")}>
<div className={styles.inner}>
<motion.div className={styles.copy} variants={heroGroup} initial={animateIn ? "hidden" : false} animate="shown">
{announcement && <motion.div variants={item}>
{announcement.href
? <a className={styles.announcement} href={announcement.href} onClick={() => { playUiSound("click"); announcement.onClick?.(); }}>{announcement.label}<UiIcon iconRole="arrow-right" className={styles.arrow} size={14} strokeWidth={1.75} aria-hidden="true" /></a>
: <button type="button" className={styles.announcement} onClick={() => { playUiSound("click"); announcement.onClick?.(); }}>{announcement.label}<UiIcon iconRole="arrow-right" className={styles.arrow} size={14} strokeWidth={1.75} aria-hidden="true" /></button>}
</motion.div>}
<motion.h1 variants={item} className={[shell.title, styles.title].join(" ")}>{title}</motion.h1>
{description && <motion.p variants={item} className={[shell.description, styles.description].join(" ")}>{description}</motion.p>}
{(primaryAction || secondaryAction) && <motion.div variants={item} className={styles.actions}>
{primaryAction && <ActionLink action={primaryAction} kind="primary" />}
{secondaryAction && <ActionLink action={secondaryAction} kind="secondary" />}
</motion.div>}
{command && <motion.div variants={item} className={styles.install}>
<span className={styles.prompt} aria-hidden="true">$</span>
<code className={styles.command}>{command.command}</code>
<CopyButton value={command.command} label={command.copyLabel ?? "설치 명령어 복사하기"} copiedLabel={command.copiedLabel} errorLabel={command.errorLabel} iconOnly variant="plain" />
</motion.div>}
{meta && meta.length > 0 && <motion.ul variants={item} className={styles.meta}>{meta.map(entry => <li key={entry}>{entry}</li>)}</motion.ul>}
</motion.div>
{media && <motion.div className={styles.media} variants={item} initial={animateIn ? "hidden" : false} animate="shown">{media}</motion.div>}
</div>
</section></MotionConfig>;
}
export default HeroContent;
hero-lumen-data.tshero-lumen-data.tshero-lumen-data.tsts
import type { LineChartDatum } from "../../line-chart/line-chart";
/** 매출 분석 제품 루멘의 예시 데이터예요. 금액은 원화로 보여 주고, 내 제품의 숫자로 바꿔 쓰세요. */
export type LumenRange = "30d" | "90d" | "12m";
export const lumenRanges: { value: LumenRange; label: string }[] = [
{ value: "30d", label: "30일" },
{ value: "90d", label: "90일" },
{ value: "12m", label: "12개월" },
];
/** A smooth, deterministic walk: a trend, one slow wave, and a little noise that repeats for the same seed. */
function walk(count: number, start: number, end: number, wave: number, seed: number) {
let s = seed;
const rand = () => { s = (s * 16807) % 2147483647; return s / 2147483647 - .5; };
return Array.from({ length: count }, (_, i) => {
const t = i / (count - 1);
return Math.round(start + (end - start) * (t * t * .35 + t * .65) + Math.sin(t * Math.PI * 2.3) * wave + rand() * wave * .6);
});
}
const months = ["10월", "11월", "12월", "1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월"];
function series(range: LumenRange): LineChartDatum[] {
if (range === "12m") {
const now = walk(12, 9_800, 36_400, 2_600, 7), before = walk(12, 6_200, 21_900, 2_200, 19);
return months.map((month, i) => ({ key: `m${i}`, label: `${i < 3 ? 2025 : 2026}년 ${month}`, axisLabel: i % 2 === 0 ? month : undefined, values: { mrr: now[i], last: before[i] } }));
}
const days = range === "30d" ? 30 : 13;
const step = range === "30d" ? 1 : 7;
const now = walk(days, range === "30d" ? 640 : 4_300, range === "30d" ? 1_960 : 9_800, range === "30d" ? 260 : 900, range === "30d" ? 3 : 11);
const before = walk(days, range === "30d" ? 520 : 3_600, range === "30d" ? 1_180 : 6_100, range === "30d" ? 220 : 700, 29);
const end = new Date(Date.UTC(2026, 8, 24));
return now.map((value, i) => {
const date = new Date(end.getTime() - (days - 1 - i) * step * 86_400_000);
const label = date.toLocaleDateString("ko-KR", { month: "long", day: "numeric", timeZone: "Asia/Seoul" });
const every = range === "30d" ? 7 : 3;
return { key: date.toISOString().slice(0, 10), label, axisLabel: (days - 1 - i) % every === 0 ? label : undefined, values: { mrr: value, last: before[i] } };
});
}
export const lumenSeries: Record<LumenRange, LineChartDatum[]> = { "30d": series("30d"), "90d": series("90d"), "12m": series("12m") };
export interface LumenKpi {
id: string;
label: string;
tone: "accent" | "success" | "warning" | "danger";
byRange: Record<LumenRange, { value: string; change: string; data: number[] }>;
}
export const lumenKpis: LumenKpi[] = [
{ id: "mrr", label: "MRR", tone: "accent", byRange: {
"30d": { value: "₩6.8억", change: "+8.2%", data: walk(12, 446, 483, 4, 5) },
"90d": { value: "₩6.8억", change: "+19.9%", data: walk(12, 402, 483, 6, 9) },
"12m": { value: "₩6.8억", change: "+51.7%", data: walk(12, 318, 483, 8, 13) },
} },
{ id: "nrr", label: "순 유지율", tone: "success", byRange: {
"30d": { value: "118%", change: "+3%p", data: walk(12, 112, 118, 1.5, 17) },
"90d": { value: "116%", change: "+5%p", data: walk(12, 109, 116, 1.8, 21) },
"12m": { value: "114%", change: "+9%p", data: walk(12, 103, 114, 2.4, 23) },
} },
{ id: "new", label: "신규 고객", tone: "success", byRange: {
"30d": { value: "214", change: "+12%", data: walk(12, 5, 9, 2, 31) },
"90d": { value: "602", change: "+18%", data: walk(12, 38, 56, 6, 37) },
"12m": { value: "2,140", change: "+34%", data: walk(12, 120, 214, 16, 41) },
} },
{ id: "churn", label: "이탈 MRR", tone: "success", byRange: {
"30d": { value: "₩854만", change: "−14%", data: walk(12, 9, 6, 1.2, 43) },
"90d": { value: "₩2,996만", change: "−9%", data: walk(12, 26, 21, 2, 47) },
"12m": { value: "₩1.4억", change: "−22%", data: walk(12, 12, 7, 1.4, 53) },
} },
];
export interface LumenMover {
name: string;
logo: string;
/** Mono marks draw in the text color through a mask; color marks keep their own colors. */
mono?: boolean;
change: string;
amount: number;
}
export const lumenMovers: LumenMover[] = [
{ name: "Linear", logo: "/block-logos/linear-color.svg", change: "엔터프라이즈로 옮겼어요", amount: 4_200 },
{ name: "Raycast", logo: "/block-logos/raycast-color.svg", change: "좌석 120개를 추가했어요", amount: 2_850 },
{ name: "Vercel", logo: "/block-logos/vercel.svg", mono: true, change: "연간 선결제", amount: 1_900 },
{ name: "Loom", logo: "/block-logos/loom-color.svg", change: "좌석 40개를 줄였어요", amount: -1_240 },
{ name: "Supabase", logo: "/block-logos/supabase-color.svg", change: "예측 기능을 추가했어요", amount: 980 },
{ name: "Framer", logo: "/block-logos/framer.svg", mono: true, change: "스케일 요금제로 올렸어요", amount: 760 },
];
export const lumenInsight: Record<LumenRange, { lead: string; rest: string }> = {
"30d": { lead: "MRR이 이번 달 ₩5,096만 늘었어요.", rest: "62%는 스케일 요금제의 확장 계약 14건에서 나왔고, Linear가 가장 컸어요." },
"90d": { lead: "MRR이 이번 분기 ₩1.1억 늘었어요.", rest: "3월 이후 처음으로 확장 계약이 신규 계약을 앞질렀어요." },
"12m": { lead: "MRR이 12개월 동안 ₩2.3억 늘었어요.", rest: "110%가 넘는 순 유지율이 신규 고객보다 더 크게 기여했어요." },
};
hero-lumen.module.csshero-lumen.module.csshero-lumen.module.csscss
/* Centered, one full screen: copy over a drifting mesh, and a product window that rises from the bottom edge and runs off it.
The hero is exactly one screen tall (never shorter than 600px); the window takes whatever height the copy leaves. */
/* The section is the container, so its children carry the side gutters and the queries below can change them. */
.hero { isolation: isolate; display: flex; height: 100vh; height: 100svh; min-height: 600px; flex-direction: column; padding-top: clamp(88px, 14svh, 144px); text-align: center; }
/* The mesh fills the screen. It is authored per theme: tints a few steps off the page, so it reads as light rather than color. */
.mesh {
--mesh-base: var(--background);
--mesh-1: #ffe6d8; --mesh-2: #e3dcff; --mesh-3: #dbe4ff; --mesh-4: #f0e0ff; --mesh-5: #e0eefc;
z-index: -1;
}
:global(:root[data-theme="dark"]) .mesh { --mesh-1: #2a1822; --mesh-2: #1c1a44; --mesh-3: #141c40; --mesh-4: #231842; --mesh-5: #0e2133; }
.copy { position: relative; display: grid; justify-items: center; gap: var(--space-5); max-width: 900px; margin: 0 auto; padding-inline: var(--space-10); }
.title { max-width: 14ch; font-size: clamp(2.5rem, 1.2rem + 5cqi, var(--text-5xl)); }
.description { max-width: 590px; }
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); margin-top: var(--space-3); }
.fine { margin: 0; color: var(--text-muted); font-size: var(--text-sm); }
/* Stage: fills the rest of the screen and is the visible crop. The window runs past its bottom edge; the mask fades it into the
mesh, so there is no hard edge. The halo above leaves room for the light behind the window. */
.stage { --halo: 96px; position: relative; flex: 1 1 0; box-sizing: border-box; width: 100%; min-height: 0; max-width: calc(1200px + 2 * var(--space-10)); margin: calc(var(--space-12) - var(--halo)) auto 0; overflow: hidden; padding: var(--halo) var(--space-10) 0; -webkit-mask-image: linear-gradient(to bottom, #000 62%, transparent 100%); mask-image: linear-gradient(to bottom, #000 62%, transparent 100%); }
.glow {
position: absolute; z-index: -1; top: calc(var(--halo) - 110px); left: 10%; right: 10%; height: 240px;
background: radial-gradient(50% 50% at 50% 50%, var(--hero-glow), transparent 72%);
filter: blur(36px);
--hero-glow: color-mix(in oklab, color-mix(in oklab, var(--accent) 35%, #7d8cff) 36%, transparent);
}
:global(:root[data-theme="dark"]) .glow { --hero-glow: color-mix(in oklab, color-mix(in oklab, var(--accent) 30%, #6e7cff) 58%, transparent); }
.window { position: relative; }
/* A thin light catches the top edge, the way a real display does in a dark room. */
:global(:root[data-theme="dark"]) .window::after { content: ""; position: absolute; top: 0; left: 12%; right: 12%; height: 1px; background: linear-gradient(to right, transparent, color-mix(in oklab, #b9c2ff 70%, transparent), transparent); pointer-events: none; }
.perspective { display: flex; justify-content: center; perspective: 2200px; perspective-origin: 50% 0%; }
.tilt { position: relative; flex: none; transform-origin: 50% 0%; will-change: transform; }
.scaler { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
/* Reduced motion: the window is already in place; drop the GPU layer kept for the entrance. */
@media (prefers-reduced-motion: reduce) { .tilt { will-change: auto; } }
/* The window. */
.window {
display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; overflow: hidden;
border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--foreground);
text-align: left; font-size: var(--text-sm);
box-shadow: 0 1px 1px rgb(20 24 40 / .04), 0 24px 48px -16px rgb(20 24 40 / .16), 0 60px 120px -40px rgb(40 48 110 / .22);
}
:global(:root[data-theme="dark"]) .window {
border-color: color-mix(in oklab, var(--foreground) 14%, transparent);
box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 9%, transparent), 0 24px 48px -16px rgb(0 0 0 / .6), 0 60px 140px -40px rgb(40 50 140 / .35);
}
.chrome { position: relative; display: flex; height: 40px; align-items: center; justify-content: center; border-bottom: 1px solid var(--border-subtle); background: color-mix(in oklab, var(--surface-muted) 55%, var(--surface)); }
.lights { position: absolute; left: 16px; display: flex; gap: 7px; }
.lights i { width: 11px; height: 11px; border-radius: 50%; background: color-mix(in oklab, var(--foreground) 14%, transparent); }
.url { border-radius: 8px; padding: 3px 14px; background: var(--surface); color: var(--text-muted); font-size: var(--text-xs); box-shadow: inset 0 0 0 1px var(--border-subtle); }
.app { display: grid; grid-template-columns: 224px minmax(0, 1fr); min-height: 0; }
.window[data-narrow] .app { grid-template-columns: minmax(0, 1fr); }
.sidebar { display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--border-subtle); padding: 14px 12px; background: color-mix(in oklab, var(--surface-muted) 40%, var(--surface)); }
.workspace { display: flex; align-items: center; gap: 10px; padding: 6px 8px 10px; }
.wsMark { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 6px; background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; }
.wsName { flex: 1; font-weight: 500; }
.dim { color: var(--text-muted); }
.search { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; border: 1px solid var(--border-subtle); border-radius: 9px; padding: 6px 8px; background: var(--surface); color: var(--text-muted); }
.search kbd { margin-left: auto; font: inherit; font-size: var(--text-xs); }
.nav { display: grid; gap: 1px; }
.navItem { display: flex; align-items: center; gap: 10px; border-radius: 8px; padding: 6px 8px; color: var(--text-secondary); }
.navItem svg { color: var(--text-muted); }
.navItem[data-active] { background: var(--accent-subtle); color: var(--foreground); font-weight: 500; }
.navItem[data-active] svg { color: var(--accent); }
.groupLabel { margin: 18px 8px 6px; color: var(--text-muted); font-size: var(--text-xs); }
.dot { width: 8px; height: 8px; margin: 0 3px; border-radius: 50%; background: var(--text-muted); }
.dot[data-tone="accent"] { background: var(--accent); }
.dot[data-tone="danger"] { background: var(--danger); }
.me { display: flex; align-items: center; gap: 10px; margin-top: auto; border-top: 1px solid var(--border-subtle); padding: 12px 6px 2px; }
.me span:not([role]) { display: grid; flex: 1; line-height: 1.3; }
.me strong { font-weight: 500; }
.me small { color: var(--text-muted); font-size: var(--text-xs); }
.main { display: grid; align-content: start; gap: 16px; min-width: 0; padding: 22px 24px; }
.head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.h2 { margin: 0; font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); }
.sub { margin: 2px 0 0; color: var(--text-muted); font-size: var(--text-xs); }
.range { flex: none; }
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.window[data-narrow] .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi { min-width: 0; border: 1px solid var(--border-subtle); border-radius: 12px; padding: 14px 16px 12px; background: var(--surface); }
.grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); gap: 12px; }
.window[data-narrow] .grid { grid-template-columns: minmax(0, 1fr); }
.card { display: grid; align-content: start; gap: 14px; min-width: 0; border: 1px solid var(--border-subtle); border-radius: 12px; padding: 16px 18px; background: var(--surface); }
.cardHead { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.insight { display: flex; gap: 8px; max-width: 46ch; margin: 0; color: var(--text-secondary); line-height: 1.45; }
.insight strong { color: var(--foreground); font-weight: 500; }
.insightIcon { flex: none; margin-top: 2px; color: var(--accent); }
.legend { display: flex; flex: none; gap: 14px; color: var(--text-muted); font-size: var(--text-xs); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 14px; height: 2px; border-radius: 2px; background: var(--accent); }
.swatch[data-last] { background: repeating-linear-gradient(to right, color-mix(in oklab, var(--foreground) 46%, var(--surface)) 0 4px, transparent 4px 7px); }
.window[data-narrow] .legend { display: none; }
.cardTitle { display: flex; justify-content: space-between; font-weight: 500; }
.cardTitle span { color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }
.movers { display: grid; margin: 0; padding: 0; list-style: none; }
.movers li { display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--border-subtle); padding: 10px 0; }
.movers li:first-child { border-top: 0; padding-top: 2px; }
.logo { flex: none; width: 20px; height: 20px; background: center / contain no-repeat; }
.logo[data-mono] { background: var(--foreground); -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat; }
.who { display: grid; flex: 1; min-width: 0; line-height: 1.3; }
.who strong { font-weight: 500; }
.who small { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
.amount { color: var(--success); font-variant-numeric: tabular-nums; font-weight: 500; }
.amount[data-down] { color: var(--danger); }
@container hero (max-width: 560px) {
.hero { padding-top: 84px; }
.copy { gap: var(--space-4); padding-inline: var(--space-4); }
.actions { width: 100%; margin-top: var(--space-2); }
.actions > * { flex: 1 1 auto; }
.stage { --halo: 56px; margin-top: calc(var(--space-10) - var(--halo)); padding-inline: 0; }
}
/* KPI tiles reuse Arc Sparkline; its caption wraps so the label sits above a larger value. */
.kpi :global(figcaption) { flex-wrap: wrap; row-gap: 4px; }
.kpi :global(figcaption) > :first-child { flex-basis: 100%; font-size: var(--text-xs); color: var(--text-muted); }
.kpi :global(figcaption) strong { margin-left: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-body); }
.kpi :global(figcaption) small { align-self: center; }
hero-lumen.tsxhero-lumen.tsxhero-lumen.tsxtsx
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { MotionConfig, motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import { BookOpen01Icon, ChartBarLineIcon, ChartLineData01Icon, DashboardSpeed01Icon, Settings01Icon, SparklesIcon, UnfoldMoreIcon, UserGroupIcon } from "@hugeicons/core-free-icons";
import { LineChart } from "../../line-chart/line-chart";
import { Sparkline } from "../../sparkline/sparkline";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Avatar } from "../../avatar/avatar";
import { avatar } from "../../lib/media";
import { motionTokens } from "../../lib/motion-tokens";
import { ActionLink } from "./hero-content";
import type { HeroAction } from "./hero-content";
import { HeroMesh } from "./hero-mesh";
import type { HeroMeshPoint } from "./hero-mesh";
import { heroGroup, heroRise } from "./hero-motion";
import { lumenInsight, lumenKpis, lumenMovers, lumenRanges, lumenSeries } from "./hero-lumen-data";
import type { LumenRange } from "./hero-lumen-data";
import shell from "./hero-section.module.css";
import styles from "./hero-lumen.module.css";
import { UiIcon } from "../../theme/ui-icon";
/** A soft mesh across the whole screen: cool tints at the top, a warm one low behind the window, all a few steps off the page. */
const MESH: HeroMeshPoint[] = [
{ x: .1, y: .92, spread: .7 },
{ x: .92, y: .86, spread: .66 },
{ x: .14, y: .08, spread: .64 },
{ x: .86, y: .04, spread: .6 },
{ x: .5, y: .38, spread: .46 },
];
const NAV = [
{ label: "개요", icon: DashboardSpeed01Icon, active: true },
{ label: "매출", icon: ChartLineData01Icon },
{ label: "고객", icon: UserGroupIcon },
{ label: "코호트", icon: ChartBarLineIcon },
{ label: "예측", icon: SparklesIcon },
{ label: "보고서", icon: BookOpen01Icon },
];
const VIEWS = [{ label: "엔터프라이즈 확장", tone: "accent" }, { label: "이탈 위험", tone: "danger" }, { label: "EU 고객", tone: "neutral" }];
/** 예시 숫자는 달러 기준으로 만들어 두었고, 화면에는 1달러 = 1,400원으로 바꿔 원화로 보여 줘요. */
const WON_PER_DOLLAR = 1400;
const moneyFormat = new Intl.NumberFormat("ko-KR", { style: "currency", currency: "KRW", maximumFractionDigits: 0 });
const compactFormat = new Intl.NumberFormat("ko-KR", { style: "currency", currency: "KRW", notation: "compact", maximumFractionDigits: 1 });
const money = { format: (value: number) => moneyFormat.format(value * WON_PER_DOLLAR) };
const compact = (value: number) => compactFormat.format(value * WON_PER_DOLLAR);
/** Width the dashboard is designed at, wide and narrow. It is drawn at that size and scaled to fit, like a screenshot. */
const WIDE = 1200, NARROW = 600;
export interface HeroLumenProps {
primaryAction?: HeroAction;
secondaryAction?: HeroAction;
animateIn?: boolean;
className?: string;
}
/**
* Centered hero, one full screen: a headline over a drifting mesh, and a live revenue dashboard rising from the bottom edge. The
* window settles from a tilt as it enters and runs off the bottom of the screen into a fade. It is real markup built from the
* chart components; its range control works.
*/
export function HeroLumen({ primaryAction = { label: "무료로 시작하기", doneLabel: "체험을 시작했어요" }, secondaryAction = { label: "데모 신청하기", doneLabel: "데모를 신청했어요" }, animateIn = true, className }: HeroLumenProps) {
const calm = !!useReducedMotion();
const item = heroRise;
const stage = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ width: WIDE, scale: 1 });
useLayoutEffect(() => {
const node = stage.current;
if (!node) return;
const measure = () => {
const css = getComputedStyle(node);
const width = node.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight);
const design = width < 720 ? NARROW : WIDE;
// On a phone the window keeps a legible size and crops evenly at both edges instead of shrinking to fit.
setBox({ width: design, scale: design === NARROW ? Math.max(.6, Math.min(1, width / design)) : Math.min(1, width / design) });
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, []);
const narrow = box.width === NARROW;
const height = narrow ? 700 : 760;
// Reduced motion: Motion skips every transform (rise, zoom, tilt entrance); opacity still fades briefly.
return <MotionConfig reducedMotion="user"><section className={[shell.hero, shell.screen, styles.hero, className].filter(Boolean).join(" ")}>
<HeroMesh className={styles.mesh} points={MESH} grain={.18} speed={.55} />
<motion.div className={styles.copy} variants={heroGroup} initial={animateIn ? "hidden" : false} animate="shown">
<motion.h1 variants={item} className={[shell.title, styles.title].join(" ")}>매출이 움직이는 순간을 모두 확인해요</motion.h1>
<motion.p variants={item} className={[shell.description, styles.description].join(" ")}>루멘이 Stripe, HubSpot, 데이터 웨어하우스를 연결하고 MRR이 바뀌는 순간 이유를 설명해 줘요. SQL도, 오래된 스프레드시트도 필요 없어요.</motion.p>
<motion.div variants={item} className={styles.actions}>
<ActionLink action={primaryAction} kind="primary" />
<ActionLink action={secondaryAction} kind="secondary" />
</motion.div>
<motion.p variants={item} className={styles.fine}>14일 동안 무료예요. 연결은 4분이면 끝나요.</motion.p>
</motion.div>
<motion.div
ref={stage}
className={styles.stage}
initial={animateIn ? { opacity: 0, y: 56 } : false}
animate={{ opacity: 1, y: 0 }}
transition={calm ? { duration: motionTokens.duration.standard } : { duration: 1.1, ease: [...motionTokens.ease.enter], delay: motionTokens.duration.moderate }}
>
<div className={styles.glow} aria-hidden="true" />
<div className={styles.perspective}>
<motion.div
className={styles.tilt}
style={{ width: box.width * box.scale, height: height * box.scale }}
initial={animateIn ? { rotateX: 22, scale: .94 } : false}
animate={{ rotateX: 0, scale: 1 }}
transition={{ duration: 1.5, ease: [...motionTokens.ease.enter], delay: motionTokens.duration.moderate }}
>
{/* The scale is a Motion value rather than a CSS transform, so layout animations inside (the date range highlight)
are projected through it and slide exactly between options at any screen width. */}
<motion.div className={styles.scaler} style={{ width: box.width, height, scale: box.scale, originX: 0, originY: 0 }}>
<LumenDashboard narrow={narrow} />
</motion.div>
</motion.div>
</div>
</motion.div>
</section></MotionConfig>;
}
/** The Lumen app window. Everything is content except the range control, which changes the data the whole screen shows. */
function LumenDashboard({ narrow }: { narrow: boolean }) {
const [range, setRange] = useState<LumenRange>("30d");
const insight = lumenInsight[range];
return <div className={styles.window} data-narrow={narrow || undefined} role="group" aria-label="루멘 매출 대시보드, 예시 데이터">
<div className={styles.chrome} aria-hidden="true">
<span className={styles.lights}><i /><i /><i /></span>
<span className={styles.url}>app.lumen.so/acme/overview</span>
</div>
<div className={styles.app}>
{!narrow && <aside className={styles.sidebar}>
<div className={styles.workspace}>
<span className={styles.wsMark}>한</span>
<span className={styles.wsName}>한빛클라우드</span>
<HugeiconsIcon icon={UnfoldMoreIcon} size={14} strokeWidth={1.75} className={styles.dim} />
</div>
<div className={styles.search}><UiIcon iconRole="search" size={14} strokeWidth={1.75} />검색<kbd>⌘K</kbd></div>
<div className={styles.nav}>
{NAV.map(({ label, icon, active }) => <div key={label} className={styles.navItem} data-active={active || undefined}><HugeiconsIcon icon={icon} size={15} strokeWidth={1.75} />{label}</div>)}
</div>
<div className={styles.groupLabel}>저장한 보기</div>
<div className={styles.nav}>
{VIEWS.map(view => <div key={view.label} className={styles.navItem}><i className={styles.dot} data-tone={view.tone} />{view.label}</div>)}
</div>
<div className={styles.me}>
<Avatar name="윤채원" src={avatar("chloe-nguyen")} size="sm" />
<span><strong>윤채원</strong><small>데이터 분석가</small></span>
<HugeiconsIcon icon={Settings01Icon} size={15} strokeWidth={1.75} className={styles.dim} />
</div>
</aside>}
<div className={styles.main}>
<header className={styles.head}>
<div>
<h2 className={styles.h2}>매출 개요</h2>
<p className={styles.sub}>2분 전에 Stripe와 동기화했어요</p>
</div>
<SegmentedControl label="기간" options={lumenRanges} value={range} onValueChange={value => setRange(value as LumenRange)} className={styles.range} />
</header>
<div className={styles.kpis}>
{lumenKpis.map(kpi => {
const now = kpi.byRange[range];
return <div key={kpi.id} className={styles.kpi}>
<Sparkline label={kpi.label} value={now.value} change={now.change} data={now.data} tone={kpi.tone} width={220} height={34} interactive={false} />
</div>;
})}
</div>
<div className={styles.grid}>
<div className={styles.card}>
<div className={styles.cardHead}>
<p className={styles.insight}><HugeiconsIcon icon={SparklesIcon} size={15} strokeWidth={1.75} className={styles.insightIcon} /><span><strong>{insight.lead}</strong> {insight.rest}</span></p>
<div className={styles.legend} aria-hidden="true"><span><i className={styles.swatch} />이번 기간</span><span><i className={styles.swatch} data-last="" />지난 기간</span></div>
</div>
<LineChart
label="순증 MRR"
data={lumenSeries[range]}
series={[{ key: "mrr", label: "이번 기간", area: true }, { key: "last", label: "지난 기간", dashed: true }]}
height={narrow ? 210 : 232}
legend={false}
formatValue={value => money.format(value)}
formatTick={compact}
/>
</div>
{!narrow && <div className={styles.card}>
<div className={styles.cardTitle}>변동이 큰 고객<span>MRR 변동</span></div>
<ul className={styles.movers}>
{lumenMovers.map(mover => <li key={mover.name}>
<span className={styles.logo} data-mono={mover.mono || undefined} style={mover.mono ? { maskImage: `url(${mover.logo})`, WebkitMaskImage: `url(${mover.logo})` } : { backgroundImage: `url(${mover.logo})` }} />
<span className={styles.who}><strong>{mover.name}</strong><small>{mover.change}</small></span>
<span className={styles.amount} data-down={mover.amount < 0 || undefined}>{mover.amount < 0 ? "−" : "+"}{money.format(Math.abs(mover.amount))}</span>
</li>)}
</ul>
</div>}
</div>
</div>
</div>
</div>;
}
export default HeroLumen;
hero-mesh.module.csshero-mesh.module.csshero-mesh.module.csscss
/* The canvas fades in over its own CSS render once it has painted, so the first frame never flashes. */
.mesh { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.canvas { display: block; width: 100%; height: 100%; opacity: 0; transition: opacity var(--duration-considered, .48s) var(--ease-standard); }
.mesh[data-painted] .canvas { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .canvas { transition: none; } }
hero-mesh.tsxhero-mesh.tsxhero-mesh.tsxtsx
"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import styles from "./hero-mesh.module.css";
/**
* One point of a mesh, in the same model the Gradient mesh editor saves: a position as a fraction of the box (0 to 1 from the
* top left) and a reach. The color is not given here: it is read from the CSS variable `--mesh-{n}` on the element, so light
* and dark themes each author their own palette in CSS and the canvas follows a theme switch without a remount.
*/
export interface HeroMeshPoint {
x: number;
y: number;
/** Reach of the color as a fraction of the box, 0.15 to 1.2. */
spread: number;
}
export interface HeroMeshProps {
points: readonly HeroMeshPoint[];
/** Film grain, 0 to 1. */
grain?: number;
/** Drift speed. 1 loops every 20 seconds; 0 holds the mesh still. */
speed?: number;
className?: string;
style?: CSSProperties;
}
type Rgb = [number, number, number];
/** Same loop as the Gradient mesh editor: integer frequencies, so the drift is seamless. */
const PERIOD = 20, TRAVEL = .075, BREATH = .08, MAX = 8;
/** The drift is slow, so thirty frames a second is indistinguishable from sixty and halves the cost. */
const FRAME = 1000 / 30;
const VS = `attribute vec2 a; void main() { gl_Position = vec4(a, 0.0, 1.0); }`;
const FS = `
precision mediump float;
uniform vec2 uRes;
uniform vec3 uBase;
uniform vec4 uP[${MAX}];
uniform vec3 uC[${MAX}];
uniform int uN;
uniform float uGrain;
float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * .1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }
void main() {
vec2 uv = vec2(gl_FragCoord.x / uRes.x, 1.0 - gl_FragCoord.y / uRes.y);
vec3 col = uBase;
for (int i = 0; i < ${MAX}; i++) {
if (i >= uN) break;
vec4 p = uP[i];
float d = length((uv - p.xy) / max(p.z, 1e-4));
col = mix(col, uC[i], 1.0 - smoothstep(0.0, 1.0, d));
}
float n = hash(floor(gl_FragCoord.xy)) + hash(floor(gl_FragCoord.xy) + 19.19) - 1.0;
float lum = dot(col, vec3(.2126, .7152, .0722));
col += n * (uGrain * .1 * (.55 + 1.8 * lum * (1.0 - lum)) + .6 / 255.0);
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;
function hashOf(text: string) {
let h = 2166136261;
for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
const UNIFORMS = ["uRes", "uBase", "uP", "uC", "uN", "uGrain"] as const;
/** Resolves any CSS color (hex, oklch, color-mix) to sRGB through a one pixel 2D canvas. */
function toRgb(color: string, scratch: CanvasRenderingContext2D): Rgb {
scratch.clearRect(0, 0, 1, 1);
scratch.fillStyle = "#000";
scratch.fillStyle = color;
scratch.fillRect(0, 0, 1, 1);
const [r = 0, g = 0, b = 0] = scratch.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
}
/** The static CSS render of the same mesh: shown on the server, before the canvas paints, and when WebGL is missing. */
function cssMesh(points: readonly HeroMeshPoint[]): CSSProperties {
const stops = [0, .2, .4, .6, .8, 1].map(t => [t, Math.round((1 - t * t * (3 - 2 * t)) * 100)] as const);
const layers = points.map((p, i) => `radial-gradient(ellipse ${p.spread * 100}% ${p.spread * 100}% at ${p.x * 100}% ${p.y * 100}%, ${stops.map(([t, a]) => `color-mix(in srgb, var(--mesh-${i + 1}) ${a}%, transparent) ${t * 100}%`).join(", ")})`);
return { backgroundColor: "var(--mesh-base)", backgroundImage: layers.reverse().join(", ") };
}
/**
* A lean, non-editing render of the Gradient mesh: a single WebGL triangle composites each point over the last with the
* editor's smoothstep falloff and grain, drifting on the editor's seamless loop. It draws at thirty frames a second only while
* it is on screen and the tab is visible, and draws one still frame under reduced motion.
*/
export function HeroMesh({ points, grain = .35, speed = 1, className, style }: HeroMeshProps) {
const root = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const reduced = !!useReducedMotion();
const key = JSON.stringify(points);
useEffect(() => {
const host = root.current, node = canvas.current;
if (!host || !node) return;
const gl = node.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });
const scratch = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!gl || !scratch) return;
const compile = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
const prog = gl.createProgram()!;
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const u = Object.fromEntries(UNIFORMS.map(name => [name, gl.getUniformLocation(prog, name)])) as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
const pts = (JSON.parse(key) as HeroMeshPoint[]).slice(0, MAX);
const drift = pts.map((_, i) => { const h = hashOf(`mesh-${i}`); return { ph1: (h % 628) / 100, ph2: ((h >>> 10) % 628) / 100, k1: 1 + ((h >>> 20) % 2), k2: 1 + ((h >>> 22) % 2) }; });
const P = new Float32Array(MAX * 4), C = new Float32Array(MAX * 3);
let base: Rgb = [1, 1, 1];
const readColors = () => {
const css = getComputedStyle(host);
base = toRgb(css.getPropertyValue("--mesh-base").trim() || "#fff", scratch);
pts.forEach((_, i) => { const c = toRgb(css.getPropertyValue(`--mesh-${i + 1}`).trim() || "#fff", scratch); C.set(c, i * 3); });
};
let width = 0, height = 0;
const size = () => {
// The mesh has no detail finer than its grain, so one canvas pixel per CSS pixel is enough on any screen.
width = Math.max(1, Math.round(host.clientWidth));
height = Math.max(1, Math.round(host.clientHeight));
node.width = width; node.height = height;
};
let t = hashOf(key) % PERIOD;
const draw = () => {
const a = (2 * Math.PI * t) / PERIOD;
pts.forEach((p, i) => {
const d = drift[i]!;
P[i * 4] = p.x + TRAVEL * Math.sin(a * d.k1 + d.ph1);
P[i * 4 + 1] = p.y + TRAVEL * Math.cos(a * d.k2 + d.ph2);
P[i * 4 + 2] = p.spread * (1 + BREATH * Math.sin(a + d.ph1 * .7));
});
gl.viewport(0, 0, width, height);
gl.uniform2f(u.uRes, width, height);
gl.uniform3f(u.uBase, base[0], base[1], base[2]);
gl.uniform4fv(u.uP, P);
gl.uniform3fv(u.uC, C);
gl.uniform1i(u.uN, pts.length);
gl.uniform1f(u.uGrain, grain);
gl.drawArrays(gl.TRIANGLES, 0, 3);
host.dataset.painted = "";
};
readColors();
size();
draw();
const moving = !reduced && speed > 0;
let visible = false, frame = 0, last = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (now - last < FRAME) return;
const dt = last ? Math.min(.1, (now - last) / 1000) : 0;
last = now;
t = (t + dt * speed) % PERIOD;
draw();
};
const sync = () => {
const run = moving && visible && document.visibilityState === "visible";
if (run && !frame) { last = 0; frame = requestAnimationFrame(tick); }
if (!run && frame) { cancelAnimationFrame(frame); frame = 0; }
};
const io = new IntersectionObserver(([entry]) => { if (!entry) return; visible = entry.isIntersecting; sync(); });
io.observe(host);
const ro = new ResizeObserver(() => { size(); draw(); });
ro.observe(host);
const theme = () => { readColors(); draw(); };
const mo = new MutationObserver(theme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-accent", "class"] });
const scheme = matchMedia("(prefers-color-scheme: dark)");
scheme.addEventListener("change", theme);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
io.disconnect(); ro.disconnect(); mo.disconnect();
scheme.removeEventListener("change", theme);
document.removeEventListener("visibilitychange", sync);
delete host.dataset.painted;
};
}, [key, grain, speed, reduced]);
return <div ref={root} className={[styles.mesh, className].filter(Boolean).join(" ")} style={{ ...cssMesh(points), ...style }} aria-hidden="true">
<canvas ref={canvas} className={styles.canvas} />
</div>;
}
export default HeroMesh;
hero-motion.tshero-motion.tshero-motion.tsts
import { useSyncExternalStore } from "react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { motionTokens } from "../../lib/motion-tokens";
export type Bezier = [number, number, number, number];
export const ease = {
enter: [...motionTokens.ease.enter] as Bezier,
standard: [...motionTokens.ease.standard] as Bezier,
inOut: [...motionTokens.ease.inOut] as Bezier,
};
/** The one entrance a hero plays: children rise in reading order, once. */
export const heroGroup: Variants = { hidden: {}, shown: { transition: { staggerChildren: motionTokens.stagger.line, delayChildren: .04 } } };
export const heroRise: Variants = {
hidden: { opacity: 0, y: 12, filter: `blur(${motionTokens.blur.subtle}px)` },
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: .64, ease: ease.enter } },
};
export const heroFade: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.standard } } };
export const instant: Transition = { duration: 0 };
const noop = () => () => {};
/**
* Reduced motion, read only after hydration. The server cannot know the preference, so the first client render matches the
* server's full motion markup and the reduced branch takes over on the next render, before anything has moved.
*/
export function useHeroReducedMotion() {
const hydrated = useSyncExternalStore(noop, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
hero-relay.module.csshero-relay.module.csshero-relay.module.csscss
/* Split, one full screen: the whole hero is a workflow canvas. A dot grid fills the screen, strongest behind the graph, and the
copy sits on the quiet side of it. */
.hero {
--dot: color-mix(in oklab, var(--foreground) 13%, transparent);
--wire: color-mix(in oklab, var(--foreground) 17%, transparent);
--node-shadow: 0 1px 1px rgb(20 24 40 / .04), 0 8px 24px -12px rgb(20 24 40 / .14);
isolation: isolate;
display: grid;
align-items: center;
background: color-mix(in oklab, var(--accent) 2%, var(--background));
}
:global(:root[data-theme="dark"]) .hero {
--dot: color-mix(in oklab, var(--foreground) 17%, transparent);
--wire: color-mix(in oklab, var(--foreground) 20%, transparent);
--node-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 7%, transparent), 0 12px 32px -14px rgb(0 0 0 / .7);
}
.hero::before {
content: ""; position: absolute; z-index: -1; inset: 0; pointer-events: none;
background-image: radial-gradient(circle at 1px 1px, var(--dot) 1px, transparent 1.5px);
background-size: 22px 22px;
-webkit-mask-image: radial-gradient(70% 80% at 72% 52%, #000 30%, transparent 85%);
mask-image: radial-gradient(70% 80% at 72% 52%, #000 30%, transparent 85%);
}
.inner { display: grid; box-sizing: border-box; width: 100%; max-width: 1320px; grid-template-columns: minmax(0, .92fr) minmax(0, 1fr); align-items: center; gap: var(--space-16); margin: 0 auto; padding: clamp(88px, 12svh, 120px) var(--space-10) var(--space-12); }
.copy { display: grid; justify-items: start; gap: var(--space-5); text-align: left; }
.title { max-width: 12ch; font-size: clamp(2.5rem, 1.2rem + 4.6cqi, 4.5rem); }
.description { max-width: 500px; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-3); }
.fine { margin: 0; color: var(--text-muted); font-size: var(--text-sm); }
/* The graph column: a small toolbar and the scaled graph under it. */
.canvas { display: grid; justify-items: center; gap: var(--space-4); min-width: 0; }
.toolbar { display: flex; width: min(100%, 640px); align-items: center; justify-content: space-between; gap: var(--space-3); }
.file { display: inline-flex; min-width: 0; align-items: center; gap: 8px; color: var(--text-secondary); font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace); font-size: 13px; }
.live { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--text-muted); transition: background-color var(--duration-standard) var(--ease-standard); }
.live[data-on] { background: var(--success); }
.stage { position: relative; width: min(100%, 640px); }
.graph { position: absolute; top: 0; left: 50%; translate: -50% 0; transform-origin: 50% 0; }
.edges { position: absolute; inset: 0; overflow: visible; }
.wire { fill: none; stroke: var(--wire); stroke-width: 1.25; }
.wire[data-skipped] { stroke-dasharray: 3 4; opacity: .7; }
.flow { fill: none; stroke: var(--accent); stroke-width: 1.75; stroke-linecap: round; }
/* A node: what the step is, what it found, and its state. The running step takes the accent. */
.node {
position: absolute; display: flex; align-items: center; gap: 12px;
border: 1px solid var(--border); border-radius: 14px; padding: 0 14px;
background: var(--surface); box-shadow: var(--node-shadow);
font-size: 13px; line-height: 1.3;
transition: border-color var(--duration-standard) var(--ease-standard), opacity var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard);
}
.node[data-status="running"] { border-color: transparent; background: color-mix(in oklab, var(--accent) 4%, var(--surface)); }
.node[data-status="skipped"] { opacity: .5; box-shadow: none; }
.icon { display: grid; width: 20px; height: 20px; flex: none; place-items: center; }
.logo { width: 18px; height: 18px; background: center / contain no-repeat; }
.glyph { color: var(--text-secondary); }
.text { display: grid; flex: 1; min-width: 0; }
.text strong { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.text small { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
.state { display: grid; flex: none; justify-items: end; }
.stateInner { display: inline-flex; align-items: center; gap: 6px; }
.meta { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.check { color: var(--success); }
.skip { color: var(--text-muted); }
.pending { display: block; width: 10px; height: 10px; border: 1.5px solid var(--wire); border-radius: 50%; }
.spinner { display: block; width: 13px; height: 13px; flex: none; border: 1.5px solid color-mix(in oklab, var(--accent) 22%, transparent); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { rotate: 360deg; } }
/* Compact nodes on a phone: icon and state on top, the name and its result under them. */
.node[data-compact] { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-content: center; gap: 8px 4px; padding: 0 14px; }
@container hero (max-width: 560px) { .node[data-compact] { gap: 6px 4px; padding: 0 10px; } }
.node[data-compact] .text { grid-column: 1 / -1; }
.node[data-compact] .state { grid-column: 2; grid-row: 1; }
/* The run summary under the graph. */
.summary { position: absolute; left: 0; display: flex; height: 44px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--border-subtle); padding: 0 2px; color: var(--text-muted); font-size: 13px; }
.event { display: inline-flex; align-items: center; gap: 12px; }
.event code { border: 1px solid var(--border-subtle); border-radius: 6px; padding: 1px 7px; background: var(--surface); color: var(--text-secondary); font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace); font-size: var(--text-xs); }
.num { font-variant-numeric: tabular-nums; }
.result { display: inline-flex; align-items: center; gap: 7px; }
.result[data-done] { color: var(--foreground); }
.result[data-done] svg { color: var(--success); }
@container hero (max-width: 900px) {
.inner { grid-template-columns: minmax(0, 1fr); gap: var(--space-10); }
.hero::before { -webkit-mask-image: radial-gradient(90% 60% at 50% 78%, #000 30%, transparent 85%); mask-image: radial-gradient(90% 60% at 50% 78%, #000 30%, transparent 85%); }
}
@container hero (max-width: 560px) {
.inner { gap: var(--space-8); padding: 80px var(--space-4) var(--space-6); }
.copy { gap: var(--space-4); }
.actions { width: 100%; margin-top: var(--space-1); }
.actions > * { flex: 1 1 auto; }
.canvas { gap: var(--space-3); }
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
.node, .live { transition: none; }
}
hero-relay.tsxhero-relay.tsxhero-relay.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { DatabaseIcon, SplitIcon } from "@hugeicons/core-free-icons";
import { Button } from "../../button/button";
import { motionTokens } from "../../lib/motion-tokens";
import { ActionLink } from "./hero-content";
import type { HeroAction } from "./hero-content";
import { ease, heroGroup, heroRise, useHeroReducedMotion } from "./hero-motion";
import shell from "./hero-section.module.css";
import styles from "./hero-relay.module.css";
import { UiIcon } from "../../theme/ui-icon";
type NodeId = "trigger" | "lookup" | "branch" | "slack" | "linear" | "warehouse";
type Status = "idle" | "running" | "done" | "skipped";
/** Sample events. Each one runs the same workflow; the branch decides which actions it reaches. */
const EVENTS = [
{ customer: "한빛테크", amount: "₩672만", plan: "엔터프라이즈, 212석", routed: true, issue: "ONB-482", total: 412, ms: { lookup: 118, branch: 2, slack: 164, linear: 231, warehouse: 58 } },
{ customer: "새봄상회", amount: "₩34만", plan: "팀, 9석", routed: false, issue: "", total: 171, ms: { lookup: 96, branch: 1, slack: 0, linear: 0, warehouse: 61 } },
{ customer: "누리헬스", amount: "₩1,764만", plan: "엔터프라이즈, 540석", routed: true, issue: "ONB-483", total: 436, ms: { lookup: 131, branch: 2, slack: 149, linear: 244, warehouse: 66 } },
] as const;
type RelayEvent = (typeof EVENTS)[number];
/**
* One run in phases. Odd phases run a step, even phases carry the data along the edges out of it; the last phase holds the
* finished run on screen before the next event arrives. Durations in milliseconds.
*/
const PHASES = [900, 420, 380, 640, 380, 360, 440, 760, 2800];
const DONE = PHASES.length - 1;
const STEPS: Record<NodeId, [start: number, end: number]> = { trigger: [1, 2], lookup: [3, 4], branch: [5, 6], slack: [7, 8], linear: [7, 8], warehouse: [7, 8] };
const ACTIONS = ["slack", "linear", "warehouse"] as const;
function statusOf(id: NodeId, phase: number, event: RelayEvent): Status {
const [start, end] = STEPS[id];
if ((id === "slack" || id === "linear") && !event.routed && phase >= 6) return "skipped";
return phase < start ? "idle" : phase < end ? "running" : "done";
}
/** The graph is drawn at a fixed size and scaled to its column, like a screenshot, so it never reflows mid run. */
type Box = { x: number; y: number; w: number; h: number };
const LAYOUTS = {
wide: {
width: 640, height: 524, summary: 480,
nodes: { trigger: { x: 170, y: 0, w: 300, h: 60 }, lookup: { x: 170, y: 112, w: 300, h: 60 }, branch: { x: 170, y: 224, w: 300, h: 60 }, slack: { x: 0, y: 356, w: 200, h: 84 }, linear: { x: 220, y: 356, w: 200, h: 84 }, warehouse: { x: 440, y: 356, w: 200, h: 84 } } as Record<NodeId, Box>,
},
narrow: {
width: 344, height: 316, summary: 0,
nodes: { trigger: { x: 0, y: 0, w: 344, h: 52 }, lookup: { x: 0, y: 78, w: 344, h: 52 }, branch: { x: 0, y: 156, w: 344, h: 52 }, slack: { x: 0, y: 244, w: 108, h: 72 }, linear: { x: 118, y: 244, w: 108, h: 72 }, warehouse: { x: 236, y: 244, w: 108, h: 72 } } as Record<NodeId, Box>,
},
};
type Layout = (typeof LAYOUTS)[keyof typeof LAYOUTS];
const EDGES: { from: NodeId; to: NodeId; phase: number }[] = [
{ from: "trigger", to: "lookup", phase: 2 },
{ from: "lookup", to: "branch", phase: 4 },
{ from: "branch", to: "slack", phase: 6 },
{ from: "branch", to: "linear", phase: 6 },
{ from: "branch", to: "warehouse", phase: 6 },
];
function edgePath(layout: Layout, from: NodeId, to: NodeId) {
const a = layout.nodes[from], b = layout.nodes[to];
const x1 = a.x + a.w / 2, y1 = a.y + a.h, x2 = b.x + b.w / 2, y2 = b.y;
const mid = (y2 - y1) / 2;
return `M ${x1} ${y1} C ${x1} ${y1 + mid} ${x2} ${y2 - mid} ${x2} ${y2}`;
}
const RESULTS: Record<(typeof ACTIONS)[number], (event: RelayEvent) => string> = {
slack: event => `${event.ms.slack} ms 만에 보냈어요`,
linear: event => `이슈 ${event.issue}, ${event.ms.linear} ms 만에 만들었어요`,
warehouse: event => `${event.ms.warehouse} ms 만에 저장했어요`,
};
const logo = (name: string) => <span className={styles.logo} style={{ backgroundImage: `url(/block-logos/${name}.svg)` }} aria-hidden="true" />;
export interface HeroRelayProps {
primaryAction?: HeroAction;
secondaryAction?: HeroAction;
animateIn?: boolean;
className?: string;
}
/**
* Split hero, one full screen: the copy beside a live workflow graph on a dotted canvas. Sample Stripe events arrive on their
* own and run the workflow node by node; the data draws along each edge, the branch decides which actions run, and every step
* reports its time. "Send test event" runs the next sample at once. The run pauses off screen, and under reduced motion each
* event shows its finished run without playing.
*/
export function HeroRelay({ primaryAction = { label: "시작하기", doneLabel: "워크스페이스를 만들었어요" }, secondaryAction = { label: "문서 보기", doneLabel: "문서를 여는 중이에요" }, animateIn = true, className }: HeroRelayProps) {
const item = heroRise;
const reduced = useHeroReducedMotion();
const root = useRef<HTMLElement>(null);
const stage = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<keyof typeof LAYOUTS>("wide");
const [scale, setScale] = useState(1);
const [run, setRun] = useState({ index: 0, count: 2418, phase: 0 });
const [visible, setVisible] = useState(false);
useLayoutEffect(() => {
const node = stage.current;
if (!node) return;
const measure = () => {
const width = node.clientWidth;
const next = width < 520 ? "narrow" : "wide";
setMode(next);
setScale(Math.min(1, width / LAYOUTS[next].width));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, []);
// The run only plays while the hero is on screen and the tab is visible.
useEffect(() => {
const node = root.current;
if (!node) return;
let onScreen = false;
const sync = () => setVisible(onScreen && document.visibilityState === "visible");
const io = new IntersectionObserver(([entry]) => { if (!entry) return; onScreen = entry.isIntersecting; sync(); });
io.observe(node);
document.addEventListener("visibilitychange", sync);
return () => { io.disconnect(); document.removeEventListener("visibilitychange", sync); };
}, []);
useEffect(() => {
if (reduced || !visible) return;
const timer = window.setTimeout(() => setRun(current => current.phase < DONE
? { ...current, phase: current.phase + 1 }
: { index: (current.index + 1) % EVENTS.length, count: current.count + 1, phase: 0 }), PHASES[run.phase]);
return () => window.clearTimeout(timer);
}, [run.phase, run.index, reduced, visible]);
const sendTest = () => setRun(current => ({ index: (current.index + 1) % EVENTS.length, count: current.count + 1, phase: 1 }));
const layout = LAYOUTS[mode];
const event = EVENTS[run.index]!;
// Reduced motion: every event is shown as its finished run; the button moves to the next one at once.
const phase = reduced ? DONE : run.phase;
const status = (id: NodeId) => statusOf(id, phase, event);
const narrow = mode === "narrow";
// Reduced motion: Motion skips every transform (rise, tilt entrance); opacity still fades briefly.
return <MotionConfig reducedMotion="user"><section ref={root} className={[shell.hero, shell.screen, styles.hero, className].filter(Boolean).join(" ")}>
<div className={styles.inner}>
<motion.div className={styles.copy} variants={heroGroup} initial={animateIn ? "hidden" : false} animate="shown">
<motion.h1 variants={item} className={[shell.title, styles.title].join(" ")}>모든 이벤트를 몇 밀리초 만에 처리해요</motion.h1>
<motion.p variants={item} className={[shell.description, styles.description].join(" ")}>릴레이가 Stripe, GitHub, Postgres의 웹훅을 재시도, 분기, 실행 기록이 있는 워크플로로 바꿔 줘요. 단계는 TypeScript로 쓰거나 캔버스에서 연결해요.</motion.p>
<motion.div variants={item} className={styles.actions}>
<ActionLink action={primaryAction} kind="primary" />
<ActionLink action={secondaryAction} kind="secondary" />
</motion.div>
<motion.p variants={item} className={styles.fine}>월 10,000회 실행까지 무료예요. 카드는 필요 없어요.</motion.p>
</motion.div>
<motion.div
className={styles.canvas}
initial={animateIn ? { opacity: 0, y: 24 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: ease.enter, delay: .28 }}
>
<div className={styles.toolbar}>
<span className={styles.file}><i className={styles.live} data-on={visible || reduced || undefined} aria-hidden="true" />on-invoice-paid.ts</span>
<Button variant="secondary" size="sm" onClick={sendTest}>테스트 이벤트 보내기</Button>
</div>
<div ref={stage} className={styles.stage} style={{ height: layout.height * scale }}>
<div className={styles.graph} role="group" aria-label={`릴레이 워크플로, ${event.customer} 예시 실행`} style={{ width: layout.width, height: layout.height, transform: `scale(${scale})` }}>
<svg className={styles.edges} width={layout.width} height={layout.height} viewBox={`0 0 ${layout.width} ${layout.height}`} aria-hidden="true">
{EDGES.map(edge => {
const d = edgePath(layout, edge.from, edge.to);
const skipped = status(edge.to) === "skipped";
const lit = phase >= edge.phase && !skipped;
return <g key={`${edge.from}-${edge.to}`}>
<path d={d} className={styles.wire} data-skipped={skipped || undefined} />
<motion.path
d={d}
className={styles.flow}
initial={false}
animate={{ pathLength: lit ? 1 : 0, opacity: lit ? 1 : 0 }}
transition={lit && !reduced ? { pathLength: { duration: PHASES[edge.phase]! / 1000, ease: ease.inOut }, opacity: { duration: motionTokens.duration.micro } } : { duration: 0 }}
/>
</g>;
})}
</svg>
<RelayNode box={layout.nodes.trigger} status={status("trigger")} icon={logo("stripe-color")} title="청구서 결제 완료" sub={`${event.customer}, ${event.amount}`} />
<RelayNode box={layout.nodes.lookup} status={status("lookup")} icon={logo("hubspot-color")} title="HubSpot에서 계정 찾기" sub={status("lookup") === "done" ? event.plan : "요금제, 좌석, 담당자"} meta={`${event.ms.lookup} ms`} />
<RelayNode box={layout.nodes.branch} status={status("branch")} icon={<HugeiconsIcon icon={SplitIcon} size={17} strokeWidth={1.75} className={styles.glyph} aria-hidden="true" />} title="금액이 ₩140만 초과" sub={status("branch") === "done" ? event.routed ? "예, 팀에 알려요" : "아니요, 기록만 해요" : "조건"} meta={`${event.ms.branch} ms`} />
{ACTIONS.map(id => {
const s = status(id);
const copy = {
slack: { icon: logo("slack-color"), title: narrow ? "Slack" : "#매출 채널에 알리기", waiting: "Slack" },
linear: { icon: logo("linear-color"), title: narrow ? "Linear" : "Linear 이슈 만들기", waiting: "온보딩 팀" },
warehouse: { icon: <HugeiconsIcon icon={DatabaseIcon} size={16} strokeWidth={1.75} className={styles.glyph} aria-hidden="true" />, title: narrow ? "웨어하우스" : "웨어하우스에 저장", waiting: "Postgres" },
}[id];
const sub = s === "skipped" ? "건너뛰었어요" : s === "done" ? narrow ? `${event.ms[id]} ms` : RESULTS[id](event) : s === "running" ? "실행 중이에요" : narrow ? "기다려요" : copy.waiting;
return <RelayNode key={id} box={layout.nodes[id]} compact status={s} icon={copy.icon} title={copy.title} sub={sub} />;
})}
{!narrow && <div className={styles.summary} style={{ top: layout.summary, width: layout.width }}>
<span className={styles.event}><code>invoice.paid</code><span>실행 <span className={styles.num}>{run.count.toLocaleString("ko-KR")}</span>번째</span></span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={phase === DONE ? `done-${run.count}` : phase === 0 ? "waiting" : "running"}
className={styles.result}
data-done={phase === DONE || undefined}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: motionTokens.duration.fast, ease: ease.standard }}
>
{phase === DONE ? <><UiIcon iconRole="check" size={14} strokeWidth={2} aria-hidden="true" /><span><span className={styles.num}>{event.total} ms</span> 만에 끝났어요</span></> : phase === 0 ? "다음 이벤트를 기다려요" : <><i className={styles.spinner} aria-hidden="true" />실행 중이에요</>}
</motion.span>
</AnimatePresence>
</div>}
</div>
</div>
</motion.div>
</div>
</section></MotionConfig>;
}
function RelayNode({ box, status, icon, title, sub, meta, compact = false }: { box: Box; status: Status; icon: ReactNode; title: string; sub: string; meta?: string; compact?: boolean }) {
return <div className={styles.node} data-status={status} data-compact={compact || undefined} style={{ left: box.x, top: box.y, width: box.w, height: box.h }}>
<span className={styles.icon}>{icon}</span>
<span className={styles.text}>
<strong>{title}</strong>
<small>{sub}</small>
</span>
<span className={styles.state}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
className={styles.stateInner}
initial={{ opacity: 0, scale: .6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: .6 }}
transition={motionTokens.spring.snappy}
>
{status === "running" ? <i className={styles.spinner} role="img" aria-label="실행 중이에요" />
: status === "done" ? <>{meta && <span className={styles.meta}>{meta}</span>}<UiIcon iconRole="check" size={14} strokeWidth={2} className={styles.check} role="img" aria-label="완료했어요" /></>
: status === "skipped" ? <UiIcon iconRole="minus" size={14} strokeWidth={2} className={styles.skip} role="img" aria-label="건너뛰었어요" />
: <i className={styles.pending} role="img" aria-label="기다리는 중이에요" />}
</motion.span>
</AnimatePresence>
</span>
</div>;
}
export default HeroRelay;
hero-section.module.csshero-section.module.csshero-section.module.csscss
/* Shared shell for the three hero variants. Each variant adds its own module for its one idea. */
.hero { position: relative; container: hero / inline-size; overflow: clip; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(2.25rem, 1.3rem + 4.4cqi, 4.5rem); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-lg); line-height: var(--leading-body); text-wrap: pretty; }
@container hero (max-width: 560px) {
.description { font-size: var(--text-base); }
}
/* The designed variants are one full screen each, edge to edge, like Hero signup. The small viewport unit keeps them from
hiding under mobile browser bars. Your own content (HeroContent) keeps its natural height inside your page. */
.screen { width: 100%; min-height: 100vh; min-height: 100svh; }
/* Preview: the hero owns the whole frame; the design switch floats over its top edge as a small glass pill. */
.preview { position: relative; display: grid; width: 100%; min-height: 100vh; min-height: 100svh; }
.preview > .hero { min-height: inherit; }
.switcher {
position: absolute; z-index: 20; top: var(--space-4); left: 50%; translate: -50% 0;
display: flex; max-width: calc(100% - 2 * var(--space-4));
border: 1px solid color-mix(in oklab, var(--foreground) 10%, transparent); border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--surface) 64%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.5); backdrop-filter: blur(18px) saturate(1.5);
box-shadow: 0 1px 1px rgb(20 24 40 / .04), 0 10px 30px -12px rgb(20 24 40 / .22);
}
:global(:root[data-theme="dark"]) .switcher { border-color: color-mix(in oklab, var(--foreground) 14%, transparent); background: color-mix(in oklab, var(--surface) 58%, transparent); box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 8%, transparent), 0 10px 30px -12px rgb(0 0 0 / .6); }
/* The segmented control drops its own well; the glass is its surface. */
.switcher .switch { border: 0; border-radius: var(--radius-pill); background: transparent; }
.switcher .switch :global(button) { border-radius: var(--radius-pill); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
hero-section.tsxhero-section.tsxhero-section.tsxtsx
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import SegmentedControl from "../../segmented-control/segmented-control";
import { HeroLumen } from "./hero-lumen";
import { HeroRelay } from "./hero-relay";
import { HeroCadence } from "./hero-cadence";
import { HeroMesh } from "./hero-mesh";
import type { HeroMeshPoint } from "./hero-mesh";
import { HeroContent } from "./hero-content";
import type { HeroAction, HeroInstallCommand } from "./hero-content";
import styles from "./hero-section.module.css";
export { HeroCadence, HeroContent, HeroLumen, HeroMesh, HeroRelay };
export type { HeroAction, HeroInstallCommand, HeroMeshPoint };
export type HeroSectionVariant = "centered" | "split" | "minimal";
export interface HeroSectionProps {
/**
* `centered` is a headline over a drifting mesh with a live dashboard rising from the bottom edge, `split` sets the copy
* beside a live workflow graph that runs sample events node by node, and `minimal` is large type over a mesh gradient.
* Each design fills one full screen.
*/
variant?: HeroSectionVariant;
/** Plays the entrance once on mount. 기본은 true예요. */
animateIn?: boolean;
/** The main call to action. With `doneLabel` and no `href`, the button confirms in place. */
primaryAction?: HeroAction | null;
/** The second call to action. */
secondaryAction?: HeroAction | null;
/**
* Your own headline. When set, the hero renders your content in the chosen layout (see `HeroContent`) instead of the
* designed sample, together with the props below.
*/
title?: string;
description?: string;
announcement?: HeroAction | null;
/** Install commands for your own content; the first one is shown with a copy button. */
install?: HeroInstallCommand[] | null;
/** A visual beside your own content in the split layout. */
media?: ReactNode;
/** Small facts under your own content, such as the version and license. */
meta?: string[];
/** Kept for compatibility; highlighted phrases render as plain text. */
highlight?: string;
className?: string;
}
/**
* A full screen landing page hero in three designs: a product screenshot in perspective over a drifting mesh, a live workflow
* graph that routes sample events, and editorial type over a mesh gradient.
*/
export function HeroSection({ variant = "centered", animateIn = true, primaryAction, secondaryAction, title, description, announcement, install, media, meta, className }: HeroSectionProps) {
if (title) return <HeroContent layout={variant} title={title} description={description} announcement={announcement} primaryAction={primaryAction} secondaryAction={secondaryAction} install={install} media={media} meta={meta} animateIn={animateIn} className={className} />;
const actions = { primaryAction: primaryAction ?? undefined, secondaryAction: secondaryAction ?? undefined };
if (variant === "split") return <HeroRelay animateIn={animateIn} className={className} {...actions} />;
if (variant === "minimal") return <HeroCadence animateIn={animateIn} className={className} {...actions} />;
return <HeroLumen animateIn={animateIn} className={className} {...actions} />;
}
const variantOptions = [
{ value: "centered", label: "스크린샷" },
{ value: "split", label: "워크플로" },
{ value: "minimal", label: "메시" },
];
/**
* Preview: the hero, full screen, with a small glass switch floating over its top edge. The switch takes no space of its own,
* so every design fills the screen exactly. Switching replays the entrance.
*/
export function HeroSectionBlock({ variant: initial = "centered" }: { variant?: HeroSectionVariant }) {
const [variant, setVariant] = useState<HeroSectionVariant>(initial);
return <div className={styles.preview}>
<HeroSection key={variant} variant={variant} />
<div className={styles.switcher}>
<SegmentedControl label="히어로 디자인" options={variantOptions} value={variant} onValueChange={value => setVariant(value as HeroSectionVariant)} className={styles.switch} />
</div>
</div>;
}
export default HeroSectionBlock;
Used items
- AvatarComponent
A person's photo or initials, with an optional status dot.
- ButtonComponent
The base button with press feedback, sound and several variants.
- Copy buttonComponent
A button that copies a value to the clipboard and confirms it.
- Line chartComponent
A multi-series line chart with hover readout, legend toggles and optional areas.
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.
- SparklineComponent
A compact trend line that reveals each value when scrubbed by pointer or keyboard.