Block
Site header
A site navigation header in simple, centered, and mega menu variants.
- #arc
- #block
- #header
- #navigation
- #menu
- #mega-menu
- #site
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/site-header.jsonFiles
components/arc/blocks/site-header/site-header.module.csscomponents/arc/blocks/site-header/site-header.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 Markdownsite-header.module.csssite-header.module.csssite-header.module.csscss
/* The header is its own size container, so the layout folds by the space it has rather than the viewport. */
.header { position: relative; z-index: 20; container: site-header / inline-size; border-bottom: 1px solid transparent; background: transparent; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
.sticky { position: sticky; top: 0; }
.header[data-scrolled] { border-bottom-color: var(--border); background: var(--background); box-shadow: var(--shadow-resting); }
.inner { position: relative; max-width: 1200px; margin: 0 auto; padding: 0 var(--space-6); }
.bar { display: flex; height: 64px; align-items: center; gap: var(--space-2); }
.header[data-variant="centered"] .bar { display: grid; grid-template-columns: 1fr auto 1fr; }
.brandSlot { display: flex; min-width: 0; }
.brand { display: inline-flex; align-items: center; gap: var(--space-2); border: 0; border-radius: 12px; padding: var(--space-2) var(--space-2) var(--space-2) 0; background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); font-weight: 500; text-decoration: none; cursor: pointer; }
.brandMark { width: 22px; height: 22px; flex: none; }
.nav { min-width: 0; margin-left: var(--space-4); }
.header[data-variant="centered"] .nav { margin: 0; }
.navList { display: flex; align-items: center; gap: 2px; margin: 0; padding: 0; list-style: none; }
.header[data-variant="centered"] .navList { padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-muted); }
.navCell { display: flex; }
.navItem { position: relative; isolation: isolate; display: inline-flex; height: 36px; align-items: center; gap: 4px; border: 0; border-radius: 12px; padding: 0 var(--space-3); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
.header[data-variant="centered"] .navItem { height: 32px; border-radius: var(--radius-pill); padding: 0 14px; }
.navItem:is([data-current], [data-open]) { color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .navItem:hover { color: var(--foreground); } }
.navLabel { position: relative; }
.chevron { color: var(--text-muted); transition: transform var(--duration-standard) var(--ease-standard); }
.navItem[data-open] .chevron { transform: rotate(180deg); }
.hover { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--surface-muted); }
.indicator { position: absolute; right: var(--space-3); bottom: 4px; left: var(--space-3); height: 2px; border-radius: 2px; background: var(--accent); }
.navItem:has(.chevron) .indicator { right: calc(var(--space-3) + 18px); }
.header[data-variant="centered"] .indicator { z-index: -1; inset: 0; height: auto; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }
.actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
.header[data-variant="centered"] .actions { justify-self: end; }
.action { display: inline-flex; height: 36px; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 14px; 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); }
.secondary { background: none; color: var(--text-secondary); }
.primary { background: var(--accent); color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) {
.secondary:hover { background: var(--surface-muted); color: var(--foreground); }
.primary:hover { opacity: .9; }
}
.menuButton { position: relative; display: none; width: 40px; height: 40px; place-items: center; border: 0; border-radius: 12px; background: none; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
.menuButton:active { background: var(--surface-muted); }
.menuIcon { display: grid; place-items: center; }
/* Mega menu panel: one surface whose height springs to each section's content. */
.panel { position: absolute; top: calc(100% - 4px); right: var(--space-6); left: var(--space-6); overflow: hidden; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: 50% 0; }
.face { position: relative; padding: var(--space-3); }
.face[data-leaving] { position: absolute; top: 0; right: 0; left: 0; pointer-events: none; }
.faceGrid { display: grid; gap: var(--space-2); }
.faceGrid[data-featured] { grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); }
.panelLinks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; margin: 0; padding: 0; list-style: none; }
.panelLink { display: flex; width: 100%; height: 100%; align-items: flex-start; gap: var(--space-3); border: 0; border-radius: 16px; padding: var(--space-3); background: none; color: var(--foreground); font: inherit; text-align: left; text-decoration: none; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard); }
.panelLink:focus-visible, .feature:focus-visible { background: var(--surface-muted); }
.panelIcon { display: flex; flex: none; margin-top: 2px; color: var(--text-secondary); }
.panelText { display: grid; gap: 2px; min-width: 0; }
.panelText > span:first-child { font-size: var(--text-sm); font-weight: 500; }
.panelText > span:last-child:not(:first-child) { color: var(--text-muted); font-size: var(--text-sm); }
.feature { display: grid; align-content: start; gap: 6px; border: 0; border-radius: 18px; padding: var(--space-2) var(--space-2) var(--space-3); background: var(--surface-muted); color: var(--foreground); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
.featureImage { position: relative; display: block; overflow: hidden; aspect-ratio: 16 / 10; margin-bottom: 4px; border-radius: 12px; background: var(--border); }
.featureImage img { object-fit: cover; transition: transform var(--duration-considered) var(--ease-standard); }
.featureTitle { display: inline-flex; align-items: center; gap: 6px; padding: 0 var(--space-1); font-size: var(--text-sm); font-weight: 500; }
.featureTitle svg { color: var(--text-muted); transition: transform var(--duration-fast) var(--ease-standard); }
.featureText { padding: 0 var(--space-1); color: var(--text-muted); font-size: var(--text-sm); }
@media (hover: hover) and (pointer: fine) {
.panelLink:hover { background: var(--surface-muted); }
.feature:hover .featureImage img { transform: scale(1.03); }
.feature:hover .featureTitle svg { transform: translateX(2px); }
}
/* Mobile sheet: drops from the bar and pushes nothing; the scrim closes it. */
.scrim { position: absolute; top: 100%; right: 0; left: 0; height: 100dvh; background: oklch(0% 0 0 / .16); }
.sheet { position: absolute; top: 100%; right: 0; left: 0; overflow: hidden; border-bottom: 1px solid var(--border); background: var(--background); }
/* --site-header-sheet-max lets a header inside a fixed height scroller keep its actions in view. */
.sheetInner { max-height: var(--site-header-sheet-max, calc(100dvh - 65px)); overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-4) var(--space-5); }
.sheetList { margin: 0; padding: 0; list-style: none; }
.sheetItem { border-bottom: 1px solid var(--border-subtle); }
.sheetRow { display: flex; width: 100%; min-height: 52px; align-items: center; justify-content: space-between; gap: var(--space-3); border: 0; padding: 0 var(--space-1); background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); font-weight: 500; text-align: left; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.sheetChevron { color: var(--text-muted); transition: transform var(--duration-standard) var(--ease-standard); }
.sheetRow[aria-expanded="true"] .sheetChevron { transform: rotate(180deg); }
.sheetDot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.sheetGroup { overflow: hidden; }
.sheetGroup ul { display: grid; margin: 0; padding: 0 0 var(--space-3); list-style: none; }
.sheetLink { display: flex; width: 100%; min-height: 44px; align-items: center; gap: var(--space-3); border: 0; border-radius: 12px; padding: 0 var(--space-2); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-align: left; text-decoration: none; cursor: pointer; }
.sheetLink:active { background: var(--surface-muted); color: var(--foreground); }
.sheetActions { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); margin-top: var(--space-5); }
.sheetActions .action { height: var(--control-height-md); }
.sheetActions .secondary { border-color: var(--border); }
@container site-header (max-width: 759px) {
.inner { padding: 0 var(--space-4); }
.nav, .wideOnly, .panel { display: none; }
.menuButton { display: grid; }
.header[data-variant="centered"] .bar { display: flex; }
}
@container site-header (min-width: 760px) {
.sheet, .scrim { display: none; }
}
/* Preview frame: a small page that scrolls under the header. */
.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { position: relative; width: 100%; height: 520px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: 20px; background: var(--background); container-type: inline-size; --site-header-sheet-max: calc(520px - 67px); }
.page { margin-top: -65px; }
.pageHero { display: grid; justify-items: center; gap: var(--space-3); padding: 128px var(--space-6) 72px; background: var(--arc-gradient-soft, var(--surface-muted)); text-align: center; }
.pageHero h2 { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.pageHero p { max-width: 420px; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.pageRows { display: grid; gap: var(--space-3); padding: var(--space-6); }
.pageRow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.pageRow span { height: 120px; border-radius: 16px; background: var(--surface-muted); }
@container (max-width: 520px) { .pageRow { grid-template-columns: 1fr; } .pageRow span:not(:first-child) { display: none; } .pageHero { padding: 112px var(--space-4) 56px; } }
@media (prefers-reduced-motion: reduce) {
.header, .navItem, .chevron, .action, .panelLink, .featureImage img, .featureTitle svg, .sheetChevron { transition: none; }
.action:active { transform: none; }
}
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
site-header.tsxsite-header.tsxsite-header.tsxtsx
"use client";
import { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
import Image from "next/image";
import { AnimatePresence, LayoutGroup, motion, useIsPresent } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { BookOpen01Icon, DashboardSquare01Icon, HistoryIcon, LayoutIcon, Menu01Icon, Message02Icon, Package02Icon, PaintBoardIcon, RouteIcon } from "@hugeicons/core-free-icons";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import { playUiSound } from "../../sound/play";
import { photo } from "../../lib/media";
import { BRAND_NAME, Devs7udioMark } from "../../lib/brand-mark";
import styles from "./site-header.module.css";
import { UiIcon } from "../../theme/ui-icon";
export type SiteHeaderVariant = "simple" | "centered" | "mega";
/** One destination inside a mega menu panel. */
export interface SiteHeaderLink {
label: string;
/** One short line under the label. */
description?: string;
/** A plain decorative icon beside the label. */
icon?: ReactNode;
href?: string;
}
/** A card beside the links in a mega menu panel. */
export interface SiteHeaderFeature {
title: string;
description?: string;
href?: string;
image?: { src: string; alt: string };
}
/** A top level destination. With `links` and the mega variant it opens a panel; otherwise it is a plain link. */
export interface SiteHeaderItem {
/** Stable value, also used for `current`. */
value: string;
label: string;
href?: string;
links?: SiteHeaderLink[];
feature?: SiteHeaderFeature;
}
export interface SiteHeaderAction {
label: string;
href?: string;
onClick?: () => void;
}
/** What `onNavigate` receives. */
export interface SiteHeaderDestination {
label: string;
href?: string;
/** The top level item the destination belongs to. */
section?: string;
}
export interface SiteHeaderProps {
/** `simple` puts links beside the brand, `centered` centers them in a quiet capsule, `mega` opens panels for items with links. */
variant?: SiteHeaderVariant;
brand?: { name: string; href?: string; mark?: ReactNode };
items?: SiteHeaderItem[];
/** Value of the item that holds the current page (controlled). */
current?: string;
/** Initial current item when uncontrolled. */
defaultCurrent?: string;
/** Called when a destination inside an item is chosen, with that item's value. */
onCurrentChange?: (value: string) => void;
/** Called for every destination: items, panel links, the brand, and actions with an href. */
onNavigate?: (destination: SiteHeaderDestination) => void;
/** A quiet action before the primary one, such as 로그인. Pass null to hide it. */
secondaryAction?: SiteHeaderAction | null;
/** The one primary action at the end of the bar. Pass null to hide it. */
primaryAction?: SiteHeaderAction | null;
/** Sticks to the top of its scroll container. Defaults to true. */
sticky?: boolean;
/** The element that scrolls, when it is not the window. The header turns solid once it scrolls. */
scrollContainer?: RefObject<HTMLElement | null>;
/** Pixels of scroll before the background turns solid. Defaults to 8. */
scrollThreshold?: number;
/** Accessible name of the navigation landmark. */
label?: string;
className?: string;
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
/** Duration based springs restated as stiffness and damping, so a retarget keeps the velocity already in flight. */
const physical = (visualDuration: number, bounce: number): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const GROW = physical(motionTokens.spring.morph.visualDuration, motionTokens.spring.morph.bounce), SHRINK = physical(motionTokens.spring.settle.visualDuration, motionTokens.spring.settle.bounce), GLIDE = physical(.3, .1), SLIDE = physical(.4, .06);
const HOVER_INTENT = 70, LEAVE_GRACE = 180, TRAVEL = 36;
/** Matches the container query in site-header.module.css. */
const COLLAPSE_BELOW = 760;
/** Panel content slides in from the side of the newly opened item; opening from closed drops in from the bar. */
const faceVariants: Variants = {
hidden: (direction: number) => ({ opacity: 0, x: direction * TRAVEL, y: direction ? 0 : -6, filter: `blur(${motionTokens.blur.subtle}px)` }),
shown: { opacity: 1, x: 0, y: 0, filter: "blur(0px)", transition: { x: SLIDE, y: SLIDE, opacity: { duration: motionTokens.duration.fast, ease: enter, delay: .02 }, filter: { duration: motionTokens.duration.standard, ease: enter } } },
gone: (direction: number) => ({ opacity: 0, x: direction * -TRAVEL * .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { x: SLIDE, opacity: { duration: motionTokens.duration.instant, ease: standard }, filter: { duration: motionTokens.duration.instant, ease: standard } } }),
};
const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
const ICON = { size: 16, strokeWidth: 1.75, "aria-hidden": true } as const;
const curvedFacade = photo("curved-facade");
export const siteHeaderExampleItems: SiteHeaderItem[] = [
{
value: "product", label: "제품",
links: [
{ label: "컴포넌트", description: "바로 쓸 수 있는 컴포넌트 140개", icon: <HugeiconsIcon icon={Package02Icon} {...ICON} /> },
{ label: "블록", description: "완성된 화면 구간을 그대로 가져다 써요", icon: <HugeiconsIcon icon={DashboardSquare01Icon} {...ICON} /> },
{ label: "템플릿", description: "모든 페이지를 갖춘 시작용 사이트예요", icon: <HugeiconsIcon icon={LayoutIcon} {...ICON} /> },
{ label: "테마", description: "색, 모서리, 움직임을 조정해요", icon: <HugeiconsIcon icon={PaintBoardIcon} {...ICON} /> },
],
feature: { title: "2.4 새 소식", description: "사이트 헤더, 푸터, 히어로 영역을 더했어요.", image: { src: curvedFacade.src, alt: curvedFacade.alt } },
},
{
value: "resources", label: "자료",
links: [
{ label: "문서", description: "설치하고, 꾸미고, 조합해요", icon: <HugeiconsIcon icon={BookOpen01Icon} {...ICON} /> },
{ label: "가이드", description: "실제 제품 작업에 쓰는 패턴이에요", icon: <HugeiconsIcon icon={RouteIcon} {...ICON} /> },
{ label: "변경 내역", description: "매주 나온 릴리스를 모두 모았어요", icon: <HugeiconsIcon icon={HistoryIcon} {...ICON} /> },
{ label: "커뮤니티", description: "질문, 답변, 작품을 나눠요", icon: <HugeiconsIcon icon={Message02Icon} {...ICON} /> },
],
},
{ value: "pricing", label: "요금" },
{ value: "customers", label: "고객 사례" },
];
function useScrolled(threshold: number, container?: RefObject<HTMLElement | null>) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const node = container?.current ?? null;
const target: HTMLElement | Window = node ?? window;
const read = () => node ? node.scrollTop : window.scrollY;
// Turns solid past the threshold and clear again only near the top, so it never flickers at the edge.
const update = () => { const y = read(); setScrolled(previous => previous ? y > threshold / 2 : y > threshold); };
update();
target.addEventListener("scroll", update, { passive: true });
return () => target.removeEventListener("scroll", update);
}, [threshold, container]);
return scrolled;
}
/** A panel face reports its natural height while current; a leaving face floats out of flow and turns inert. */
function Face({ direction, reduced, onHeight, children }: { direction: number; reduced: boolean; onHeight: (height: number) => void; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const present = useIsPresent();
useLayoutEffect(() => {
const node = ref.current;
if (!node || !present) return;
onHeight(node.offsetHeight);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => onHeight(node.offsetHeight));
observer.observe(node);
return () => observer.disconnect();
}, [present, onHeight]);
return <motion.div ref={ref} className={styles.face} data-face="" data-leaving={present ? undefined : ""} inert={!present} custom={direction} variants={reduced ? fadeVariants : faceVariants} initial="hidden" animate="shown" exit="gone">
{children}
</motion.div>;
}
type DestinationProps = Omit<HTMLAttributes<HTMLElement>, "onClick" | "children"> & { link: { href?: string; label: string }; onChoose: (event: ReactMouseEvent) => void; children: ReactNode };
/** Renders an anchor when the destination has an href and a button otherwise, so demos and real sites share one path. */
function Destination({ link, onChoose, children, ...rest }: DestinationProps) {
return link.href
? <a {...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)} href={link.href} onClick={onChoose}>{children}</a>
: <button {...(rest as ButtonHTMLAttributes<HTMLButtonElement>)} type="button" onClick={onChoose}>{children}</button>;
}
/**
* A website header in three layouts. It sticks to the top and turns solid once the page scrolls, marks the current section
* with an indicator that glides between links, opens springy mega menu panels whose content slides in from the side you
* moved toward, and folds into a menu sheet on narrow containers.
*/
export const SiteHeader = forwardRef<HTMLElement, SiteHeaderProps>(function SiteHeader({
variant = "mega",
brand = { name: BRAND_NAME },
items = siteHeaderExampleItems,
current: currentProp,
defaultCurrent,
onCurrentChange,
onNavigate,
secondaryAction = { label: "로그인" },
primaryAction = { label: "시작하기" },
sticky = true,
scrollContainer,
scrollThreshold = 8,
label = "주요 메뉴",
className,
}, ref) {
const id = useId();
const reduced = !!useReducedMotion();
const scrolled = useScrolled(scrollThreshold, scrollContainer);
const [innerCurrent, setInnerCurrent] = useState(defaultCurrent);
const current = currentProp ?? innerCurrent;
const [hovered, setHovered] = useState<string | null>(null);
const [open, setOpen] = useState<{ value: string; direction: number } | null>(null);
const [panel, setPanel] = useState<{ height: number | null; grow: boolean }>({ height: null, grow: true });
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const rootRef = useRef<HTMLElement | null>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const triggerRefs = useRef(new Map<string, HTMLButtonElement | HTMLAnchorElement>());
const panelRef = useRef<HTMLDivElement>(null);
const openTimer = useRef<number | undefined>(undefined), closeTimer = useRef<number | undefined>(undefined);
const focusFirst = useRef(false);
const hasPanels = variant === "mega";
const openItem = open ? items.find(item => item.value === open.value) : undefined;
const setRefs = useCallback((node: HTMLElement | null) => {
rootRef.current = node;
if (typeof ref === "function") ref(node); else if (ref) ref.current = node;
}, [ref]);
const clearTimers = useCallback(() => { window.clearTimeout(openTimer.current); window.clearTimeout(closeTimer.current); }, []);
useEffect(() => clearTimers, [clearTimers]);
const openPanel = useCallback((value: string | null) => {
setOpen(previous => {
if (!value) return null;
if (previous?.value === value) return previous;
const from = previous ? items.findIndex(item => item.value === previous.value) : -1;
const to = items.findIndex(item => item.value === value);
return { value, direction: from < 0 ? 0 : Math.sign(to - from) };
});
if (!value) setPanel({ height: null, grow: true });
}, [items]);
const close = useCallback((restoreFocus = false) => {
clearTimers();
const was = open?.value;
openPanel(null);
if (restoreFocus && was) triggerRefs.current.get(was)?.focus();
}, [open, openPanel, clearTimers]);
const closeMenu = useCallback((restoreFocus = false) => {
setMenuOpen(false);
setExpanded(null);
if (restoreFocus) menuButtonRef.current?.focus();
}, []);
const choose = useCallback((destination: SiteHeaderDestination, section: string | undefined) => {
if (section) {
if (currentProp === undefined) setInnerCurrent(section);
onCurrentChange?.(section);
}
onNavigate?.(destination);
close();
closeMenu();
}, [close, closeMenu, currentProp, onCurrentChange, onNavigate]);
// Outside presses and Escape close whichever layer is open.
useEffect(() => {
if (!open && !menuOpen) return;
const onPointer = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) { close(); closeMenu(); } };
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { if (open) close(true); else closeMenu(true); } };
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("pointerdown", onPointer); document.removeEventListener("keydown", onKey); };
}, [open, menuOpen, close, closeMenu]);
// The sheet belongs to narrow layouts: widening the container closes it, and it holds the page still while open.
useEffect(() => {
const node = rootRef.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
if (entry.contentRect.width >= COLLAPSE_BELOW) { setMenuOpen(false); setExpanded(null); } else setOpen(null);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!menuOpen) return;
const scroller: HTMLElement = scrollContainer?.current ?? document.documentElement;
const previous = scroller.style.getPropertyValue("overflow");
scroller.style.setProperty("overflow", "hidden");
return () => { if (previous) scroller.style.setProperty("overflow", previous); else scroller.style.removeProperty("overflow"); };
}, [menuOpen, scrollContainer]);
useEffect(() => {
if (!open || !focusFirst.current) return;
focusFirst.current = false;
const frame = requestAnimationFrame(() => panelRef.current?.querySelector<HTMLElement>("[data-panel-link]")?.focus());
return () => cancelAnimationFrame(frame);
}, [open]);
function onTriggerPointerEnter(event: ReactPointerEvent, value: string) {
if (event.pointerType !== "mouse") return;
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
if (open) openPanel(value);
else openTimer.current = window.setTimeout(() => openPanel(value), HOVER_INTENT);
}
function onRegionPointerLeave(event: ReactPointerEvent) {
if (event.pointerType !== "mouse") return;
window.clearTimeout(openTimer.current);
closeTimer.current = window.setTimeout(() => openPanel(null), LEAVE_GRACE);
}
function onRegionPointerEnter(event: ReactPointerEvent) {
if (event.pointerType === "mouse") window.clearTimeout(closeTimer.current);
}
function onNavKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-nav-item]"));
const index = triggers.indexOf(document.activeElement as HTMLElement);
if (index < 0) return;
event.preventDefault();
const nextIndex = (index + (event.key === "ArrowRight" ? 1 : -1) + triggers.length) % triggers.length;
triggers[nextIndex]!.focus();
if (open) { const item = items[nextIndex]; openPanel(item && hasPanels && item.links?.length ? item.value : null); }
}
function onTriggerKeyDown(event: ReactKeyboardEvent, value: string) {
if (event.key === "ArrowDown") { event.preventDefault(); focusFirst.current = true; openPanel(value); if (open?.value === value) panelRef.current?.querySelector<HTMLElement>("[data-panel-link]")?.focus(); }
}
function onPanelKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
const unique = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-face]:not([data-leaving]) [data-panel-link]"));
if (!unique.length) return;
event.preventDefault();
const index = unique.indexOf(document.activeElement as HTMLElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? unique.length - 1 : event.key === "ArrowDown" ? Math.min(index + 1, unique.length - 1) : index - 1;
if (next < 0) { close(true); return; }
unique[next]?.focus();
}
// Growing carries a little life; shrinking settles without overshoot.
const onHeight = useCallback((height: number) => setPanel(previous => previous.height === height ? previous : { height, grow: previous.height === null || height > previous.height }), []);
const actionNode = (action: SiteHeaderAction, kind: "secondary" | "primary", extra = "") => {
const onClick = () => { action.onClick?.(); if (action.href) onNavigate?.({ label: action.label, href: action.href }); closeMenu(); };
const cls = `${styles.action} ${styles[kind]} ${extra}`;
return action.href ? <a className={cls} href={action.href} onClick={onClick}>{action.label}</a> : <button type="button" className={cls} onClick={onClick}>{action.label}</button>;
};
const brandNode = <Destination link={{ label: brand.name, href: brand.href }} className={styles.brand} onChoose={() => { onNavigate?.({ label: brand.name, href: brand.href }); close(); closeMenu(); }}>
{brand.mark ?? <Devs7udioMark size={22} className={styles.brandMark} />}<span>{brand.name}</span>
</Destination>;
return <header
ref={setRefs}
className={[styles.header, sticky ? styles.sticky : "", className].filter(Boolean).join(" ")}
data-variant={variant}
data-scrolled={scrolled || menuOpen || !!open ? "" : undefined}
>
<div className={styles.inner} onPointerLeave={onRegionPointerLeave} onPointerEnter={onRegionPointerEnter}>
<div className={styles.bar}>
<div className={styles.brandSlot}>{brandNode}</div>
<LayoutGroup id={id}>
<nav className={styles.nav} aria-label={label} onKeyDown={onNavKeyDown} onPointerLeave={() => setHovered(null)}>
<ul className={styles.navList}>
{items.map(item => {
const isCurrent = current === item.value;
const withPanel = hasPanels && !!item.links?.length;
const isOpen = open?.value === item.value;
const panelId = `${id}-panel`;
const common = {
className: styles.navItem,
"data-nav-item": "",
"data-current": isCurrent ? "" : undefined,
"data-open": isOpen ? "" : undefined,
onPointerEnter: (event: ReactPointerEvent) => { if (event.pointerType === "mouse") setHovered(item.value); if (withPanel) onTriggerPointerEnter(event, item.value); else if (event.pointerType === "mouse" && open) closeTimer.current = window.setTimeout(() => openPanel(null), LEAVE_GRACE); },
onFocus: () => setHovered(null),
};
const decorations = <>
{hovered === item.value && variant !== "centered" && <motion.span key={`hover-${variant}`} layoutId={`hover-${variant}`} className={styles.hover} transition={reduced ? { duration: 0 } : GLIDE} aria-hidden="true" />}
{isCurrent && <motion.span key={`current-${variant}`} layoutId={`current-${variant}`} className={styles.indicator} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
</>;
return <li key={item.value} className={styles.navCell}>
{withPanel
? <button
{...common}
ref={(node: HTMLButtonElement | null) => { if (node) triggerRefs.current.set(item.value, node); else triggerRefs.current.delete(item.value); }}
type="button"
aria-expanded={isOpen}
aria-controls={isOpen ? panelId : undefined}
onClick={() => { clearTimers(); playUiSound(isOpen ? "close" : "open"); openPanel(isOpen ? null : item.value); }}
onKeyDown={event => onTriggerKeyDown(event, item.value)}
>
{decorations}
<span className={styles.navLabel}>{item.label}</span>
<UiIcon iconRole="chevron-down" className={styles.chevron} size={14} strokeWidth={2} aria-hidden="true" />
</button>
: <Destination
link={item}
{...common}
aria-current={isCurrent ? "page" : undefined}
onChoose={() => choose({ label: item.label, href: item.href, section: item.value }, item.value)}
>
{decorations}
<span className={styles.navLabel}>{item.label}</span>
</Destination>}
</li>;
})}
</ul>
</nav>
</LayoutGroup>
<div className={styles.actions}>
{secondaryAction && actionNode(secondaryAction, "secondary", styles.wideOnly)}
{primaryAction && actionNode(primaryAction, "primary")}
<button ref={menuButtonRef} type="button" className={styles.menuButton} aria-expanded={menuOpen} aria-controls={`${id}-sheet`} aria-label={menuOpen ? "메뉴 닫기" : "메뉴 열기"} onClick={() => { playUiSound(menuOpen ? "close" : "open"); if (menuOpen) closeMenu(); else setMenuOpen(true); }}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={menuOpen ? "close" : "open"} className={styles.menuIcon} initial={reduced ? { opacity: 0 } : { opacity: 0, rotate: menuOpen ? -45 : 45, scale: .8 }} animate={{ opacity: 1, rotate: 0, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, rotate: menuOpen ? 45 : -45, scale: .8 }} transition={reduced ? { duration: 0 } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }}>
{menuOpen ? <UiIcon iconRole="close" size={20} strokeWidth={1.75} aria-hidden="true" /> : <HugeiconsIcon icon={Menu01Icon} size={20} strokeWidth={1.75} aria-hidden="true" />}
</motion.span>
</AnimatePresence>
</button>
</div>
</div>
{hasPanels && <AnimatePresence>
{openItem?.links && <motion.div
key="panel"
id={`${id}-panel`}
ref={panelRef}
className={styles.panel}
role="region"
aria-label={openItem.label}
onKeyDown={onPanelKeyDown}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -6, scale: .985 }}
animate={{ opacity: 1, y: 0, scale: 1, height: panel.height ?? "auto" }}
exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, y: -4, scale: .99, transition: { duration: motionTokens.duration.exit, ease: standard } }}
transition={reduced ? { duration: 0 } : { height: panel.grow ? GROW : SHRINK, y: GROW, scale: GROW, opacity: { duration: motionTokens.duration.fast, ease: enter } }}
>
<AnimatePresence initial={false} custom={open?.direction ?? 0}>
<Face key={openItem.value} direction={open?.direction ?? 0} reduced={reduced} onHeight={onHeight}>
<div className={styles.faceGrid} data-featured={openItem.feature ? "" : undefined}>
<ul className={styles.panelLinks}>
{openItem.links.map(link => <li key={link.label}>
<Destination link={link} className={styles.panelLink} data-panel-link="" onChoose={() => choose({ label: link.label, href: link.href, section: openItem.value }, openItem.value)}>
{link.icon && <span className={styles.panelIcon}>{link.icon}</span>}
<span className={styles.panelText}><span>{link.label}</span>{link.description && <span>{link.description}</span>}</span>
</Destination>
</li>)}
</ul>
{openItem.feature && <Destination link={{ label: openItem.feature.title, href: openItem.feature.href }} className={styles.feature} data-panel-link="" onChoose={() => choose({ label: openItem.feature!.title, href: openItem.feature!.href, section: openItem.value }, openItem.value)}>
{openItem.feature.image && <span className={styles.featureImage}><Image src={openItem.feature.image.src} alt={openItem.feature.image.alt} fill sizes="260px" /></span>}
<span className={styles.featureTitle}>{openItem.feature.title}<UiIcon iconRole="arrow-right" size={14} strokeWidth={2} aria-hidden="true" /></span>
{openItem.feature.description && <span className={styles.featureText}>{openItem.feature.description}</span>}
</Destination>}
</div>
</Face>
</AnimatePresence>
</motion.div>}
</AnimatePresence>}
</div>
<AnimatePresence>
{menuOpen && <>
<motion.div key="scrim" className={styles.scrim} onClick={() => closeMenu()} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: standard }} aria-hidden="true" />
<motion.div
key="sheet"
id={`${id}-sheet`}
className={styles.sheet}
initial={reduced ? { opacity: 0 } : { height: 0 }}
animate={reduced ? { opacity: 1 } : { height: "auto" }}
exit={reduced ? { opacity: 0 } : { height: 0, transition: SHRINK }}
transition={reduced ? { duration: 0 } : GROW}
>
<nav className={styles.sheetInner} aria-label={label}>
<ul className={styles.sheetList}>
{items.map((item, index) => {
const isCurrent = current === item.value;
const group = !!item.links?.length && variant === "mega";
const isExpanded = expanded === item.value;
const rowMotion = { initial: reduced ? false : { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: { duration: motionTokens.duration.standard, ease: enter, delay: reduced ? 0 : .04 + index * motionTokens.stagger.item } } as const;
return <motion.li key={item.value} className={styles.sheetItem} {...rowMotion}>
{group ? <>
<button type="button" className={styles.sheetRow} data-current={isCurrent ? "" : undefined} aria-expanded={isExpanded} aria-controls={`${id}-group-${item.value}`} onClick={() => { playUiSound(isExpanded ? "close" : "open"); setExpanded(isExpanded ? null : item.value); }}>
<span>{item.label}</span><UiIcon iconRole="chevron-down" className={styles.sheetChevron} size={18} strokeWidth={1.75} aria-hidden="true" />
</button>
<AnimatePresence initial={false}>
{isExpanded && <motion.div key="group" id={`${id}-group-${item.value}`} className={styles.sheetGroup} initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={reduced ? { opacity: 1 } : { height: "auto", opacity: 1 }} exit={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>
<ul>{item.links!.map(link => <li key={link.label}>
<Destination link={link} className={styles.sheetLink} onChoose={() => choose({ label: link.label, href: link.href, section: item.value }, item.value)}>
{link.icon}<span>{link.label}</span>
</Destination>
</li>)}</ul>
</motion.div>}
</AnimatePresence>
</> : <Destination link={item} className={styles.sheetRow} data-current={isCurrent ? "" : undefined} aria-current={isCurrent ? "page" : undefined} onChoose={() => choose({ label: item.label, href: item.href, section: item.value }, item.value)}>
<span>{item.label}</span>{isCurrent && <span className={styles.sheetDot} aria-hidden="true" />}
</Destination>}
</motion.li>;
})}
</ul>
{(secondaryAction || primaryAction) && <motion.div className={styles.sheetActions} initial={reduced ? false : { opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: motionTokens.duration.standard, ease: enter, delay: reduced ? 0 : .04 + items.length * motionTokens.stagger.item }}>
{secondaryAction && actionNode(secondaryAction, "secondary")}
{primaryAction && actionNode(primaryAction, "primary")}
</motion.div>}
</nav>
</motion.div>
</>}
</AnimatePresence>
</header>;
});
SiteHeader.displayName = "SiteHeader";
const variantOptions = [{ value: "mega", label: "메가 메뉴" }, { value: "simple", label: "심플" }, { value: "centered", label: "가운데 정렬" }];
const pageLabels: Record<string, string> = { product: "제품", resources: "자료", pricing: "요금", customers: "고객 사례" };
/** Preview: the header inside a small scrolling page, with a switch between its three layouts. */
export function SiteHeaderBlock({ variant: initial = "mega" }: { variant?: SiteHeaderVariant }) {
const [variant, setVariant] = useState<SiteHeaderVariant>(initial);
const [current, setCurrent] = useState("product");
const [last, setLast] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
return <div className={styles.preview}>
<SegmentedControl label="헤더 레이아웃" options={variantOptions} value={variant} onValueChange={value => setVariant(value as SiteHeaderVariant)} />
<div className={styles.frame} ref={scrollRef}>
<SiteHeader
variant={variant}
current={current}
onCurrentChange={setCurrent}
onNavigate={destination => setLast(destination.label)}
scrollContainer={scrollRef}
secondaryAction={{ label: "로그인", onClick: () => setLast("로그인") }}
primaryAction={{ label: "시작하기", onClick: () => setLast("시작하기") }}
/>
<div className={styles.page}>
<div className={styles.pageHero}>
<h2>{pageLabels[current] ?? "devs7udio"}</h2>
<p className={styles.srOnly} aria-live="polite">{last ? `${last} 메뉴를 열었어요` : ""}</p>
</div>
<div className={styles.pageRows} aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => <div key={index} className={styles.pageRow}><span /><span /><span /></div>)}
</div>
</div>
</div>
</div>;
}
export default SiteHeaderBlock;
Used items
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.