Skip to content
Block

Site header

A site navigation header in simple, centered, and mega menu variants.

  • #arc
  • #block
  • #header
  • #navigation
  • #menu
  • #mega-menu
  • #site

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/site-header.json

site-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.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