Skip to main content

ns-ui

Nav Site Condense

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.

Usage

registry/core/nav-site-condense/demo.tsx
"use client";

import { NavSiteCondense } from "./component";

const LINKS = [
  { label: "Product", href: "#product" },
  { label: "Docs", href: "#docs" },
  { label: "Pricing", href: "#pricing" },
  { label: "Changelog", href: "#changelog" },
  { label: "Blog", href: "#blog" },
];

export default function NavSiteCondenseDemo() {
  return (
    <div id="top">
      <NavSiteCondense brand="ns-ui" links={LINKS} />
      <main className="mx-auto max-w-5xl px-4 py-16 sm:px-6">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / nav-site-condense
        </p>
        <h1 className="mt-4 max-w-lg text-2xl font-semibold text-foreground sm:text-3xl">
          Scroll to condense the bar. Click the menu button to open the sheet.
        </h1>
        <p className="mt-4 max-w-lg text-sm leading-relaxed text-ns-muted">
          The trigger is present at every width — it doubles as a full
          sitemap even on desktop. Tab to it, press Enter, then try Escape.
        </p>
        {Array.from({ length: 10 }).map((_, i) => (
          <section key={i} className="mt-10 border-t border-border pt-10">
            <h2 className="font-mono text-sm font-semibold text-foreground">
              Section {i + 1}
            </h2>
            <p className="mt-2 max-w-xl text-sm leading-relaxed text-ns-muted">
              Filler content so the page actually scrolls past the condense
              threshold. Lorem ipsum dolor sit amet, consectetur adipiscing
              elit, sed do eiusmod tempor incididunt ut labore et dolore
              magna aliqua.
            </p>
          </section>
        ))}
      </main>
    </div>
  );
}

Component

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;
  /** nav links, in order */
  links: NavLinkItem[];
  /** px of scrollY past which the bar condenses. default 24 */
  condenseAt?: number;
  /** extra classes merged onto the rendered root element */
  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,color] duration-300 hover:text-ns-accent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-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-ns-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-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-ns-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-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-ns-muted transition-colors hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-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-ns-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>
    </>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/nav-site-condense.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/nav-site-condense.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-ns-muted to text-foreground) and focus-visible:outline-2 outline-offset-4 outline-ns-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.

Props

PropTypeDefaultDescription
brand?string"ns-ui"wordmark / brand text
linksNavLinkItem[]—nav links, in order
condenseAt?number24px of scrollY past which the bar condenses. default 24
className?string—extra classes merged onto the rendered root element