Block
App header
A top bar with an open actions slot for search, language, theme and account, lifting into a floating bar on scroll.
- #arc
- #block
- #header
- #navigation
- #sticky
- #actions
- #site
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/app-header.jsonFiles
components/arc/blocks/app-header/app-header.module.csscomponents/arc/blocks/app-header/app-header.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownapp-header.module.cssapp-header.module.cssapp-header.module.csscss
.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.tsxapp-header.tsxapp-header.tsxtsx
"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;