Nav Site Condense

Navigation

A full-width site nav with the furniture a real site needs: a scroll-condensed bar, a menu trigger present at every width, and a mobile sheet built on native <dialog> for a free focus trap and Escape-to-close.

Install
npx shadcn add https://design.helpmarq.com/r/nav-site-condense.json
Source
registry/core/nav-site-condense/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// NavSiteCondense — a full-width top nav with the mechanics an actual site
// needs and this registry didn't have yet: real scroll state (a plain
// threshold class-swap, deliberately subordinate — nav-condense-rail already
// owns the continuous, measured density transition and header-scroll-pill
// already owns the silhouette morph into a pill; this component's job is
// everything neither of those ships), a menu trigger that is present at
// every width (not hidden behind a responsive breakpoint that would make it
// unreachable at some viewport sizes), and a mobile sheet built on the
// native <dialog> element for a free focus trap, Escape-to-close and
// top-layer stacking.
// ---------------------------------------------------------------------------

export interface NavLinkItem {
  label: string;
  href: string;
}

export interface NavSiteCondenseProps {
  /** wordmark / brand text */
  brand?: string;
  links: NavLinkItem[];
  /** px of scrollY past which the bar condenses. default 24 */
  condenseAt?: number;
  className?: string;
}

export function NavSiteCondense({
  brand = "ns-ui",
  links,
  condenseAt = 24,
  className = "",
}: NavSiteCondenseProps) {
  const [condensed, setCondensed] = useState(false);
  const [sheetOpen, setSheetOpen] = useState(false);

  const dialogRef = useRef<HTMLDialogElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const dialogId = useId();
  const sheetTitleId = useId();

  // -- scroll condense: a plain threshold, not a continuous interpolation --
  useEffect(() => {
    let ticking = false;
    const evaluate = () => {
      ticking = false;
      setCondensed(window.scrollY > condenseAt);
    };
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(evaluate);
    };
    evaluate(); // honest initial read: the page may load already scrolled
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [condenseAt]);

  // -- sheet: native <dialog> drives the trap, Escape and top layer --------
  useEffect(() => {
    const dlg = dialogRef.current;
    if (!dlg) return;

    if (sheetOpen && !dlg.open) dlg.showModal();
    if (!sheetOpen && dlg.open) dlg.close();

    const onCancel = (e: Event) => {
      // native Escape handling; just keep our state in sync
      e.preventDefault();
      setSheetOpen(false);
    };
    const onClose = () => setSheetOpen(false);
    const onBackdrop = (e: MouseEvent) => {
      if (e.target === dlg) setSheetOpen(false);
    };
    dlg.addEventListener("cancel", onCancel);
    dlg.addEventListener("close", onClose);
    dlg.addEventListener("click", onBackdrop);
    return () => {
      dlg.removeEventListener("cancel", onCancel);
      dlg.removeEventListener("close", onClose);
      dlg.removeEventListener("click", onBackdrop);
    };
  }, [sheetOpen]);

  const closeSheet = () => setSheetOpen(false);

  return (
    <>
      <header
        data-nav-site-condense
        className={`sticky top-0 z-40 border-b transition-[padding-block,box-shadow,background-color] duration-300 motion-reduce:transition-none ${
          condensed
            ? "border-border bg-background/95 py-2 shadow-sm backdrop-blur-sm"
            : "border-transparent bg-background py-5"
        } ${className}`}
      >
        <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
          <a
            href="#top"
            className={`shrink-0 rounded-sm font-mono font-semibold tracking-tight text-foreground transition-[font-size] duration-300 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent motion-reduce:transition-none ${
              condensed ? "text-sm" : "text-base"
            }`}
          >
            {brand}
          </a>

          <nav aria-label="Primary" className="hidden items-center gap-6 sm:flex">
            {links.map((link) => (
              <a
                key={link.href}
                href={link.href}
                className="rounded-sm text-sm text-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
              >
                {link.label}
              </a>
            ))}
          </nav>

          {/* Present at every width on purpose: a trigger hidden behind a
              responsive breakpoint is unreachable at whatever viewport a
              test (or a user on an in-between width) actually has. Doubles
              as the full-sitemap entry point even on wide screens. */}
          <button
            type="button"
            ref={triggerRef}
            aria-haspopup="dialog"
            aria-controls={dialogId}
            aria-expanded={sheetOpen}
            aria-label="Open menu"
            onClick={() => setSheetOpen(true)}
            className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-sm border border-border px-2 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-foreground/25 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
          >
            <span aria-hidden className="leading-none">
              [≡]
            </span>
          </button>
        </div>
      </header>

      <dialog
        ref={dialogRef}
        id={dialogId}
        aria-modal="true"
        aria-labelledby={sheetTitleId}
        className="ns-nav-sheet m-0 h-dvh max-h-none w-[min(20rem,88vw)] max-w-none overflow-hidden border-l border-border bg-surface p-0 text-foreground shadow-2xl"
      >
        <div className="flex h-full flex-col">
          <div className="flex items-center justify-between border-b border-border px-4 py-4">
            <h2 id={sheetTitleId} className="font-mono text-sm font-semibold text-foreground">
              {brand}
            </h2>
            <button
              type="button"
              onClick={closeSheet}
              aria-label="Close menu"
              className="rounded-sm border border-border p-1.5 text-muted transition-colors hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
            >
              <svg aria-hidden viewBox="0 0 16 16" width="14" height="14" fill="none">
                <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
              </svg>
            </button>
          </div>
          <nav aria-label="Mobile" data-nav-site-condense-sheet className="flex flex-1 flex-col overflow-y-auto p-2">
            {links.map((link) => (
              <a
                key={link.href}
                href={link.href}
                onClick={closeSheet}
                className="rounded-sm px-3 py-3 font-mono text-sm text-foreground transition-colors hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
              >
                {link.label}
              </a>
            ))}
          </nav>
        </div>

        <style>{`
          .ns-nav-sheet {
            position: fixed;
            inset: 0 0 0 auto;
            transform: translateX(100%);
            transition: transform 260ms ease, opacity 260ms ease;
            opacity: 0;
          }
          .ns-nav-sheet[open] {
            transform: translateX(0%);
            opacity: 1;
          }
          .ns-nav-sheet::backdrop {
            background: color-mix(in srgb, var(--foreground) 30%, transparent);
          }
          @media (prefers-reduced-motion: reduce) {
            .ns-nav-sheet { transition: none; }
          }
        `}</style>
      </dialog>
    </>
  );
}
Use when

the site-nav shell itself — a bar plus an always-reachable menu trigger that opens a real, focus-trapped sheet — where the scroll condense is a plain threshold state, not the point. Reach for nav-condense-rail instead when the condense transition itself is the deliverable and needs to be continuous and measured from the bar's own rendered height; reach for header-scroll-pill when the destination shape should morph into a small floating pill with a rolling active-section label. Neither of those two ships a mobile sheet or a focus trap — this one exists to be the one that does.

Build spec

Build <NavSiteCondense brand? links condenseAt? className?> where links is NavLinkItem[] ({label, href}). STRUCTURE: a sticky (position:sticky, top:0, z-40) <header data-nav-site-condense> containing a max-width row with a wordmark <a>, a hidden-below-sm inline link row (role via a real <nav aria-label="Primary">), and a menu trigger <button aria-haspopup="dialog" aria-controls aria-expanded aria-label="Open menu">. CONDENSE: deliberately minimal and subordinate to the sheet — a passive, rAF-throttled scroll listener compares window.scrollY against condenseAt (default 24px) and flips one boolean React state; past the threshold the header swaps to tighter vertical padding, a background-color/backdrop-blur and a border-bottom, and the wordmark's font-size drops a step, all via a plain Tailwind class swap with a CSS transition (motion-reduce:transition-none), not a continuous interpolation, a measured height, or a spring — nav-condense-rail already owns that continuous, measured density transition and header-scroll-pill already owns the silhouette-morph-into-a-pill; duplicating either here would be a restyle. The initial scroll position is read once on mount so a page that loads already scrolled starts in the correct state. TRIGGER: rendered unconditionally at every viewport width, never hidden behind a `md:hidden`/`sm:hidden` breakpoint — a trigger that only exists below some breakpoint is unreachable (and unclickable by anything driving the page, human or otherwise) at any width at or above it. It doubles as a full-sitemap entry point even next to the inline desktop links. SHEET: a right-anchored panel built on the native <dialog> element. Opening sets React state to true, an effect calls dialogRef.current.showModal() (never .show() — modal is what gives the free focus trap, Escape-as-cancel and top-layer stacking), closing calls .close(). The panel's own CSS transitions transform from translateX(100%) to translateX(0%) and opacity 0 to 1 keyed off the dialog's own `[open]` attribute, with @media (prefers-reduced-motion: reduce) dropping the transition entirely. Backdrop click (event target === the dialog element itself, since ::backdrop isn't a real element) and Escape (the dialog's native `cancel` event, preventDefault'd only to keep this component's own state in sync, not to block the close) both close it, and the `close` event is also listened to so state never drifts out of sync with reality. Every link inside the sheet closes it on click. The sheet's own <nav aria-label="Mobile" data-nav-site-condense-sheet> holds the link list. ACCESSIBILITY: the header's inline links and the sheet's links both carry hover (text-muted to text-foreground) and focus-visible:outline-2 outline-offset-4 outline-accent states with no outline-none on the same element (Tailwind v4 latches --tw-outline-style to none permanently if both are present, and the ring silently never paints even though the classes look correct). The dialog carries aria-modal="true" and aria-labelledby pointing at a visible heading; Escape and the native focus trap are inherited for free from showModal(). Demo is a real, tall page with no scripted auto-scroll so /preview stays the honest interactive reference, while the card's autoplay:scroll descriptor eases the iframe a short way past the condense threshold.

Tags
navheadernavigationsheetdialogscrollmobilefocus-trap