# 앱 헤더

검색·언어·테마·계정을 꽂을 액션 슬롯이 있는 상단 바예요. 스크롤하면 떠 있는 바로 바뀌어요.

- Type: block
- Theme: base
- Tags: arc, block, header, navigation, sticky, actions, site
- English title: App header
- URL: https://dsui.vercel.app/items/app-header

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| sticky | boolean | 상단 고정 | true |

## Source

### app-header.module.css

```css
.header { position: relative; z-index: 20; background: var(--background); }
.sticky { position: sticky; inset-block-start: 0; }
/* Resting: a line under the bar, nothing else. */
.bar { display: flex; min-height: calc(var(--space-16) + var(--space-2)); align-items: center; gap: var(--space-5); border: 1px solid transparent; border-block-end-color: var(--border-subtle); padding: var(--space-2) var(--space-6); background: var(--surface); transition: margin var(--duration-standard) var(--ease-standard), border-radius var(--duration-standard) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
/* Scrolled: the bar lifts off the page. It is a layer now, so the border takes the fill color and the shadow draws the edge. */
.header[data-scrolled] .bar { margin: var(--space-3) var(--space-4); border-color: var(--float-edge, var(--surface)); border-radius: var(--radius-panel); box-shadow: var(--shadow-floating); }

.brand { display: inline-flex; flex: none; align-items: center; gap: var(--space-2); color: var(--foreground); font-size: var(--label-lg); font-weight: 600; line-height: var(--leading-body); text-decoration: none; }
.brand svg, .brand img { flex: none; }

.nav { min-width: 0; }
.navList { display: flex; margin: 0; padding: 0; align-items: center; gap: var(--space-1); list-style: none; }
.navLink { position: relative; display: inline-flex; min-height: var(--control-height-sm); align-items: center; border-radius: var(--radius-pill); padding: 0 var(--space-4); color: var(--text-secondary); font-size: var(--label-md); font-weight: var(--label-weight); line-height: var(--leading-body); white-space: nowrap; text-decoration: none; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.navLink[aria-current="page"] { color: var(--accent); }
/* The fill is its own layer so it can slide between items; the label sits above it. */
.indicator { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-pill); background: var(--accent-subtle); }
.navLabel { position: relative; z-index: 1; }

.actions { display: flex; min-width: 0; flex: 1 1 auto; align-items: center; justify-content: flex-end; gap: var(--space-2); }
.wideActions { display: flex; align-items: center; gap: var(--space-2); }
.cta { display: inline-flex; min-height: var(--control-height-sm); flex: none; align-items: center; border: 1px solid var(--accent); border-radius: var(--radius-control); padding: 0 var(--space-5); background: var(--accent); color: var(--accent-foreground); font-size: var(--label-md); font-weight: var(--label-weight); line-height: var(--leading-body); white-space: nowrap; text-decoration: none; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.menuSlot { display: none; }

@media (hover: hover) and (pointer: fine) {
  .navLink:hover { background: var(--surface-muted); color: var(--foreground); }
  .navLink[aria-current="page"]:hover { background: transparent; color: var(--accent); }
  .cta:hover { box-shadow: var(--shadow-resting); opacity: .91; }
}
.navLink:active { background: var(--surface-sunken); color: var(--foreground); }
.navLink[aria-current="page"]:active { background: transparent; }
.cta:active { background: var(--accent-strong); border-color: var(--accent-strong); }

/* Below md the links and the wide actions move into the menu; the brand, the bar actions and the CTA stay. */
@media (max-width: 767px) {
  .bar { gap: var(--space-2); padding-inline: var(--space-4); }
  .nav, .wideActions { display: none; }
  .menuSlot { display: flex; }
  .cta { display: none; }
}
@media (prefers-reduced-motion: reduce) { .bar, .navLink, .cta { transition: none; } }

```

### app-header.tsx

```tsx
"use client";

import { useEffect, useId, useState } from "react";
import type { HTMLAttributes, ReactNode } from "react";
import { motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { motionTokens } from "../../lib/motion-tokens";
import { Devs7udioLogo } from "../../lib/brand-logo";
import { BRAND_NAME, Devs7udioMark } from "../../lib/brand-mark";
import { playUiSound } from "../../sound/play";
import styles from "./app-header.module.css";

export interface AppHeaderItem {
  href: string;
  label: string;
  /** Marks the section the reader is in. At most one item should carry it. */
  current?: boolean;
}

export interface AppHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  /**
   * The brand and where it points. By default the full devs7udio logo stands alone, with no mark and no text beside it.
   * Give `logo` your own node to replace it, or `mark` and `name` to show a mark with a name instead.
   */
  brand?: { name?: string; href: string; mark?: ReactNode; label?: string; logo?: ReactNode };
  items: AppHeaderItem[];
  /** The accessible name of the primary navigation, e.g. "주요 메뉴". */
  navLabel: string;
  /** Actions that stay in the bar at every width, such as the search trigger. */
  barActions?: ReactNode;
  /** Actions that move into the menu below the md breakpoint: language, theme, account. Place your own dividers. */
  actions?: ReactNode;
  cta?: { href: string; label: string };
  /** The narrow-screen menu, usually a MobileNavSheet. Left out, the links stay in the bar at every width. */
  menu?: ReactNode;
  /** Sticks to the top and lifts into a floating bar once the page has scrolled. Default true. */
  sticky?: boolean;
}

/**
 * The site's top bar, built around an open actions slot: the brand, the links with the current section marked, the
 * actions a site chooses to put there, and one call to action. Scrolling lifts it into a floating bar — a layer over
 * the page, so its border takes the fill color and the shadow draws the edge.
 */
export function AppHeader({
  className,
  brand = { href: "/" },
  items,
  navLabel,
  barActions,
  actions,
  cta,
  menu,
  sticky = true,
  ...props
}: AppHeaderProps) {
  const reduced = useReducedMotion() ?? false;
  const indicatorId = useId();
  const [scrolled, setScrolled] = useState(false);
  // The full logo is the default; a custom mark or name asks for the mark-and-name form instead.
  const showLogo = brand.logo !== undefined || (brand.mark === undefined && brand.name === undefined);

  useEffect(() => {
    if (!sticky || typeof window === "undefined") return;
    // A little past the bar's own shadow, so a one-pixel scroll does not flicker the lift.
    const read = () => setScrolled(window.scrollY > 8);
    read();
    window.addEventListener("scroll", read, { passive: true });
    return () => window.removeEventListener("scroll", read);
  }, [sticky]);

  return (
    <header
      {...props}
      data-print="hide"
      data-scrolled={scrolled ? "" : undefined}
      className={[styles.header, sticky ? styles.sticky : "", className].filter(Boolean).join(" ")}
    >
      <div className={styles.bar}>
        <a href={brand.href} className={styles.brand} aria-label={brand.label ?? (showLogo ? (brand.name ?? BRAND_NAME) : undefined)} onClick={() => playUiSound("tick")}>
          {showLogo ? (brand.logo ?? <Devs7udioLogo height={26} />) : <>
            {brand.mark ?? <Devs7udioMark size={26} />}
            <span aria-hidden={brand.label ? "true" : undefined}>{brand.name ?? BRAND_NAME}</span>
          </>}
        </a>

        <nav aria-label={navLabel} className={styles.nav}>
          <ul className={styles.navList}>
            {items.map(item => (
              <li key={item.href}>
                <a
                  href={item.href}
                  aria-current={item.current ? "page" : undefined}
                  className={styles.navLink}
                  onClick={() => playUiSound("tick")}
                >
                  {item.current ? (
                    <motion.span
                      layoutId={indicatorId}
                      className={styles.indicator}
                      transition={reduced ? { duration: 0 } : motionTokens.spring.morph}
                      aria-hidden="true"
                    />
                  ) : null}
                  <span className={styles.navLabel}>{item.label}</span>
                </a>
              </li>
            ))}
          </ul>
        </nav>

        <div className={styles.actions}>
          {barActions}
          {actions ? <div className={styles.wideActions}>{actions}</div> : null}
          {cta ? (
            <a href={cta.href} className={styles.cta} onClick={() => playUiSound("click")}>
              {cta.label}
            </a>
          ) : null}
          {menu ? <div className={styles.menuSlot}>{menu}</div> : null}
        </div>
      </div>
    </header>
  );
}

AppHeader.displayName = "AppHeader";

export default AppHeader;

```
