Skip to content
Template Empire
Browse components

Priority Overflow

Free React priority navigation for Next.js and Tailwind, fitting native links by container width and keeping the remainder in a More disclosure.

Courtyard Journal / Edition 08

A journal of the everyday

Six ways into this edition, from an afternoon walk to the books left open on our shared reading table.

Explore this edition

The sections keep their reading order as the available column changes. Each destination is a native link to a heading below.

Print room project guide

Proof notes and production decisions, kept beside the press.

Example settings
Example settings need JavaScript; the initial navigation links remain usable.

Inside the edition

Field notes

Small observations from walks, shared tables and the spaces between buildings.

Essays

Longer reflections on how a neighbourhood changes and what its residents choose to keep.

Interviews

Conversations with the people who make, repair and care for the places around them.

City walks

Routes through familiar streets, with time for a courtyard, a bookshop and a pause.

Photo studies

A close look at repeated details: doorways, lettering, working hands and afternoon light.

Reading room

Books and printed matter that informed this edition, with notes from our shared reading table.

At the print bench

Project overview

A short-run publication assembled in the print room, from first proof to finished gathering.

Paper and stock

Compare grain direction, opacity and the feel of the selected uncoated sheets.

Type specimens

Review the working type sizes, line lengths and small details before the final proof.

Registration notes

Record the alignment adjustments made between the first and second colour passes.

Proof archive

Keep dated proofs together so the decisions made at each stage remain visible.

Delivery checklist

Check the folded gatherings, packing list and agreed collection details.

Customise

520px

Code

"use client";

import { useEffect, useId, useState, type CSSProperties } from "react";

import { NavPriorityOverflow, type NavPriorityOverflowItem } from "@/components/nav-priority-overflow";

interface Destination {
  id: string;
  label: string;
  copy: string;
}

type ExampleContext = "journal" | "print-room";
type ExampleCurrent = "default" | "first" | "last";

/** Example-owner settings; none extends the installed leaf's five-prop API. */
interface PriorityOverflowExampleProps {
  width?: number;
  context?: ExampleContext;
  current?: ExampleCurrent;
}

interface ExampleSettings {
  width: number;
  context: ExampleContext;
  current: ExampleCurrent;
  requestedWidth: number;
  requestedContext: ExampleContext;
  requestedCurrent: ExampleCurrent;
}

interface CreamStyle extends CSSProperties {
  "--background": string;
  "--foreground": string;
  "--surface": string;
  "--muted": string;
  "--muted-foreground": string;
  "--card": string;
  "--border": string;
  "--ring": string;
  "--priority-overflow-surface": string;
  "--priority-overflow-ink": string;
  "--priority-overflow-border": string;
  "--priority-overflow-focus": string;
}

const CREAM_STYLE: CreamStyle = {
  "--background": "var(--color-amber-50)",
  "--foreground": "var(--color-stone-950)",
  "--surface": "var(--color-amber-100)",
  "--muted": "var(--color-stone-700)",
  "--muted-foreground": "var(--muted)",
  "--card": "var(--surface)",
  "--border": "var(--color-stone-300)",
  "--ring": "var(--foreground)",
  "--priority-overflow-surface": "var(--background)",
  "--priority-overflow-ink": "var(--foreground)",
  "--priority-overflow-border": "var(--border)",
  "--priority-overflow-focus": "var(--foreground)",
};

const JOURNAL: readonly Destination[] = [
  { id: "field-notes", label: "Field notes", copy: "Small observations from walks, shared tables and the spaces between buildings." },
  { id: "essays", label: "Essays", copy: "Longer reflections on how a neighbourhood changes and what its residents choose to keep." },
  { id: "interviews", label: "Interviews", copy: "Conversations with the people who make, repair and care for the places around them." },
  { id: "city-walks", label: "City walks", copy: "Routes through familiar streets, with time for a courtyard, a bookshop and a pause." },
  { id: "photo-studies", label: "Photo studies", copy: "A close look at repeated details: doorways, lettering, working hands and afternoon light." },
  { id: "reading-room", label: "Reading room", copy: "Books and printed matter that informed this edition, with notes from our shared reading table." },
];

const PRINT_ROOM: readonly Destination[] = [
  { id: "project-overview", label: "Project overview", copy: "A short-run publication assembled in the print room, from first proof to finished gathering." },
  { id: "paper-and-stock", label: "Paper and stock", copy: "Compare grain direction, opacity and the feel of the selected uncoated sheets." },
  { id: "type-specimens", label: "Type specimens", copy: "Review the working type sizes, line lengths and small details before the final proof." },
  { id: "registration-notes", label: "Registration notes", copy: "Record the alignment adjustments made between the first and second colour passes." },
  { id: "proof-archive", label: "Proof archive", copy: "Keep dated proofs together so the decisions made at each stage remain visible." },
  { id: "delivery-checklist", label: "Delivery checklist", copy: "Check the folded gatherings, packing list and agreed collection details." },
];

function toItems(destinations: readonly Destination[], prefix: string, current: ExampleCurrent): NavPriorityOverflowItem[] {
  const selected = current === "first" ? 0 : current === "last" ? destinations.length - 1 : 1;
  return destinations.map((destination, index) => ({
    id: destination.id,
    label: destination.label,
    href: "#" + prefix + "-" + destination.id,
    current: index === selected,
  }));
}

export function PriorityOverflowExample({
  width = 520,
  context = "journal",
  current = "default",
}: PriorityOverflowExampleProps) {
  const namespace = "priority-example-" + useId();
  const [controlsReady, setControlsReady] = useState(false);
  useEffect(() => {
    const ready = window.setTimeout(() => setControlsReady(true), 0);
    return () => window.clearTimeout(ready);
  }, []);
  const requestedWidth = Number.isFinite(width) ? Math.min(960, Math.max(240, Math.round(width))) : 520;
  const [settings, setSettings] = useState<ExampleSettings>(() => ({
    width: requestedWidth, context, current,
    requestedWidth, requestedContext: context, requestedCurrent: current,
  }));

  // Synchronise genuinely changed external example settings without remounting
  // either leaf or overwriting native-control changes on every render.
  if (settings.requestedWidth !== requestedWidth || settings.requestedContext !== context || settings.requestedCurrent !== current) {
    setSettings({
      width: requestedWidth, context, current,
      requestedWidth, requestedContext: context, requestedCurrent: current,
    });
  }

  const journalPrefix = namespace + "-journal";
  const printPrefix = namespace + "-print";
  const journalWidth = settings.context === "journal" ? settings.width : 520;
  const printWidth = settings.context === "print-room" ? settings.width : 290;
  const journalCurrent = settings.context === "journal" ? settings.current : "default";
  const printCurrent = settings.context === "print-room" ? settings.current : "default";
  const controlClass = "min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground";

  return (
    <div className="w-full min-w-0 space-y-8 text-foreground" data-priority-overflow-example="">
      <header className="max-w-xl space-y-3">
        <p className="text-xs font-semibold uppercase tracking-widest">Courtyard Journal / Edition 08</p>
        <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">A journal of the everyday</h2>
        <p className="leading-relaxed">Six ways into this edition, from an afternoon walk to the books left open on our shared reading table.</p>
      </header>

      <div className="grid grid-cols-1 min-w-0 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,20rem)]">
        <section className="min-w-0 space-y-4" data-priority-overflow-context="journal"
          style={{ width: journalWidth, maxWidth: "100%" }}>
          <h3 className="text-lg font-semibold">Explore this edition</h3>
          <NavPriorityOverflow items={toItems(JOURNAL, journalPrefix, journalCurrent)}
            navLabel="Courtyard Journal sections" moreLabel="More sections" className="text-sm" />
          <p className="text-sm leading-relaxed">The sections keep their reading order as the available column changes. Each destination is a native link to a heading below.</p>
        </section>

        <section className="min-w-0 space-y-4 rounded-xl border border-border bg-background p-5 font-serif text-foreground"
          data-priority-overflow-context="print-room"
          style={{ ...CREAM_STYLE, width: printWidth, maxWidth: "100%", boxSizing: "border-box" }}>
          <h3 className="text-xl font-semibold">Print room project guide</h3>
          <p className="leading-relaxed">Proof notes and production decisions, kept beside the press.</p>
          <NavPriorityOverflow items={toItems(PRINT_ROOM, printPrefix, printCurrent)}
            navLabel="Print room project guide" moreLabel="More guide pages" className="text-sm" />
        </section>
      </div>

      <fieldset disabled={!controlsReady} className="space-y-4 rounded-xl border border-border p-4" data-priority-overflow-example-controls="">
        <legend className="px-2 text-sm font-semibold">Example settings</legend>
        <div className="grid gap-4 sm:grid-cols-3">
          <label className="space-y-2" htmlFor={namespace + "-context"}>
            <span className="block text-sm">Context to adjust</span>
            <select id={namespace + "-context"} className={controlClass} value={settings.context}
              onChange={(event) => setSettings({ ...settings, context: event.currentTarget.value === "print-room" ? "print-room" : "journal" })}>
              <option value="journal">Courtyard Journal</option>
              <option value="print-room">Print room project guide</option>
            </select>
          </label>
          <label className="space-y-2" htmlFor={namespace + "-width"}>
            <span className="block text-sm">Requested width</span>
            <input id={namespace + "-width"} type="range" min={240} max={960} step={10}
              value={settings.width} className="min-h-11 w-full"
              onChange={(event) => setSettings({ ...settings, width: event.currentTarget.valueAsNumber })} />
          </label>
          <label className="space-y-2" htmlFor={namespace + "-current"}>
            <span className="block text-sm">Current destination</span>
            <select id={namespace + "-current"} className={controlClass} value={settings.current}
              onChange={(event) => setSettings({ ...settings, current: event.currentTarget.value === "first" ? "first" : event.currentTarget.value === "last" ? "last" : "default" })}>
              <option value="default">Editorial default</option>
              <option value="first">First destination</option>
              <option value="last">Last destination</option>
            </select>
          </label>
        </div>
        <output htmlFor={namespace + "-width"} className="block text-sm">
          {controlsReady
            ? `${settings.width} CSS px requested for the selected context; its surrounding column can constrain the actual width.`
            : "Example settings need JavaScript; the initial navigation links remain usable."}
        </output>
      </fieldset>

      <section className="space-y-5">
        <h2 className="text-2xl font-semibold tracking-tight">Inside the edition</h2>
        <div className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
          {JOURNAL.map((destination) => <section key={destination.id} id={journalPrefix + "-" + destination.id}
            className="scroll-mt-8 space-y-2 border-t border-border pt-4">
            <h3 className="text-lg font-semibold">{destination.label}</h3>
            <p className="text-sm leading-relaxed">{destination.copy}</p>
          </section>)}
        </div>
      </section>
      <section className="space-y-5">
        <h2 className="text-2xl font-semibold tracking-tight">At the print bench</h2>
        <div className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
          {PRINT_ROOM.map((destination) => <section key={destination.id} id={printPrefix + "-" + destination.id}
            className="scroll-mt-8 space-y-2 border-t border-border pt-4">
            <h3 className="text-lg font-semibold">{destination.label}</h3>
            <p className="text-sm leading-relaxed">{destination.copy}</p>
          </section>)}
        </div>
      </section>
    </div>
  );
}


<PriorityOverflowExample />

Priority Overflow is a free navbar component for Next.js, React and Tailwind that fits an ordered list of native links to its container. It measures complete labels and the More button together, keeping the largest fitting prefix inline and the remaining destinations in a native disclosure. The first items have priority, including when the current page is later in the list.

Use it for Courtyard Journal sections such as Field notes, Essays and Reading room, or a Print room project guide with longer Paper and stock and Delivery checklist links inside a narrow cream sidebar. Each instance needs its own meaningful navLabel and visible moreLabel. Width, padding and typography come from your layout; the widget supplies neither a header shell nor a router. Two instances have separate destinations and names, while the browser's native auto-popover stack may close one when the other opens.

Before JavaScript, during initial observation and whenever enhancement is unsupported, all accepted destinations appear once in a wrapped list. Enhancement requires native Popover, ResizeObserver and IntersectionObserver support, usable geometry and a safe focus boundary. Zero accepted items leave an empty named nav with no interactive child. Over 24 accepted items, a label or More name longer than 160 Unicode code points, or an invalid required name selects complete plain navigation instead of truncating names or discarding valid links. Blank required names receive defensive Navigation and More destinations names, but you still need to supply names that distinguish your landmarks.

The overflow is a nonmodal native popover containing ordinary links. Tab and Shift+Tab navigate native controls; Enter activates links, Enter or Space toggles More, and the browser handles Escape and light-dismiss. There is no menu role, arrow-key controller, focus trap, authored focus transfer or navigation interception. Opening focus and Escape return follow the supported browser's native behaviour, rather than a component focus call. When a consumer action removes the focused destination or unmounts the nav, that consumer owns any required focus recovery.

While focus is inside the nav or its popover is open, surviving links keep their actual nodes, regions and relative rendered order. A plain-held list stays wrapped, with new safe IDs appended visibly in that same list and no hidden suffix. An enhanced-held list keeps its existing split, appends new safe IDs to the overflow and uses native horizontal scrolling if the row shrinks. Existing safe hrefs, labels and current flags update immediately; removed or invalid records disappear immediately. Latest source order and eligible capacity apply after actual departure, a closed popover and a focused document allow reconciliation. If the focused More button becomes unnecessary, it remains enabled until departure; its empty panel shows allVisibleLabel without a live announcement.

Give each item a stable 1 to 64 character ASCII ID matching [A-Za-z0-9][A-Za-z0-9_-]* and a nonempty plain-text label. The first valid record for an ID wins; labels are trimmed without slicing their remaining Unicode text. Only the first current: true item in the latest accepted source order receives aria-current=page. IDs are internal identities, not automatic DOM IDs. Reusing an ID changes that anchor's destination immediately, so keep IDs tied to the same logical destinations. Relative paths, query-only and fragment-only links are accepted, as are credential-free absolute HTTP or HTTPS URLs written with ://. Empty hrefs, control characters, backslashes, protocol-relative URLs, malformed scheme-bearing HTTP spellings, credentials and all other schemes are rejected with their whole item. Acceptance does not verify the destination's contents or availability.

Full labels remain selectable and readable; closed overflow links are not additional reachable copies. The row and panel reserve space for a 2px focus outline with a 2px offset, and target rows are at least 44 CSS pixels tall. Plain lists and open panels wrap long labels; an overflowing panel scrolls natively. Horizontal writing is supported, including RTL with the first item at inline-start and unmodified Unicode labels isolated by bdi. Source and native Tab order are not reversed. Print exposes every latest safe destination once in normal flow and hides More and measurement probes; a focus-held reorder keeps its documented rendered order until departure.

Only the decorative chevron rotates, using a finite CSS transform transition; destinations, labels and focus strokes stay fully opaque and layout changes snap. Initial and live reduced motion use the current static orientation, and returning to no-preference does not replay an old toggle. Forced colours use system colours and print is static. Hidden, offscreen, Activity-hidden and unmounted instances retire or suspend their enhancement work without replacing essential native navigation. The component does not track an ancestor's unsignalled animated transform while a popover is open: close it before animating that ancestor. Arbitrary descendant CSS, vertical writing and animated root geometry are outside the supported layout contract.

Install

Add the @te registry to your components.json once:

{
  "registries": {
    "@te": "https://templateempire.io/r/{name}.json"
  }
}

Then install the component:

npx shadcn@latest add @te/nav-priority-overflow

Usage

import { NavPriorityOverflow } from "@/components/nav-priority-overflow";

export default function Page() {
  return (
    <main>
      <h1>Courtyard Journal</h1>
      <NavPriorityOverflow
        navLabel="Courtyard Journal sections"
        moreLabel="More sections"
        className="max-w-sm"
        items={[
          { id: "field-notes", label: "Field notes", href: "#field-notes" },
          { id: "essays", label: "Essays", href: "#essays", current: true },
          { id: "reading-room", label: "Reading room", href: "#reading-room" },
        ]}
      />
      <section id="field-notes">
        <h2>Field notes</h2>
        <p>Observations from the streets around the courtyard.</p>
      </section>
      <section id="essays">
        <h2>Essays</h2>
        <p>Longer pieces on the places we return to.</p>
      </section>
      <section id="reading-room">
        <h2>Reading room</h2>
        <p>Books and journals shared by our contributors.</p>
      </section>
    </main>
  );
}

Props

PropTypeDefaultDescription
itemsreadonly { id: string; label: string; href: string; current?: boolean }[]requiredOrdered native destinations, highest priority first. ID is stable, unique and 1 to 64 ASCII letters, digits, underscores or hyphens, starting with a letter or digit; the first valid duplicate wins. Labels are complete plain text. The first current: true item gets aria-current=page; false or omission is not current. Up to 24 accepted items and 160 code points per label can enhance; larger valid input remains complete plain navigation. Unsafe or invalid records are removed, not disabled. The href and focus-ownership boundaries above apply during controlled replacement too.
navLabelstringrequiredMeaningful accessible name for the native nav, distinct from other nav landmarks. Blank or invalid input falls back defensively to Navigation and keeps the list plain; that fallback does not replace your responsibility to supply a useful name.
moreLabelstringrequiredComplete visible and accessible name of the native disclosure button, included in its measured capacity. No hidden item count is appended. More than 160 code points keeps navigation plain; blank or invalid input uses More destinations and also keeps it plain.
allVisibleLabelstring"All destinations are shown"Localisable noninteractive empty-panel text when the focused More button must remain after its last overflow link disappears. Blank or invalid input uses the default. This is ordinary panel text, not an aria-live status or a new destination.
classNamestringundefinedClasses merged with cn() onto the root for width, padding, typography and ordinary layout. Measured width and font changes affect capacity. Arbitrary descendant selectors, vertical writing and animated root geometry are not supported customisation boundaries.

Customisation

Four Tier 3 colour properties resolve where the component is used: --priority-overflow-surface defaults to var(--background, Canvas) for opaque backing; --priority-overflow-ink to var(--foreground, CanvasText) for labels and chevron; --priority-overflow-border to var(--border, CanvasText) for a nonessential separator; and --priority-overflow-focus to var(--foreground, CanvasText) for the 2px outward outline. Set matching background and foreground semantics within a cream or dark scope, then bind the component properties to those aliases. Keep the reserved focus gutters and check actual text and focus contrast on your painted backing; token names alone cannot guarantee consumer colours. The chevron uses --dur-1 with a 150ms fallback and --ease-out-quart with a cubic-bezier(0.25,1,0.5,1) fallback. Use a valid CSS duration such as 200ms or 0.2s, not a bare number; capacity and placement remain instant. No Motion or GSAP package is required: the installed closure is the leaf, its sibling private model and utils, with clsx and tailwind-merge in a React 19 and Tailwind 4 host.

.print-room-guide {
  --priority-overflow-surface: var(--card, var(--background, Canvas));
  --priority-overflow-ink: var(--card-foreground, var(--foreground, CanvasText));
  --priority-overflow-border: var(--border, CanvasText);
  --priority-overflow-focus: var(--card-foreground, var(--foreground, CanvasText));
  --dur-1: 200ms;
}

Frequently asked questions

Is Priority Overflow free for commercial use?
Yes, for personal and commercial projects under the repository's MIT licence with the Commons Clause condition. Keep the required copyright, licence and Commons Clause notices in source. Distributing these components as a component library requires a separate agreement. See LICENCE.md for the complete terms. You remain responsible for your destination URLs and page content.
How do keyboard focus and unsupported browsers behave?
The nav uses native anchors and a native More button with ordinary links in a nonmodal popover, rather than menu roles or a focus trap. Surviving focused links stay in their current rendered regions while layout changes wait. The browser owns popover opening focus and Escape return; the component never calls focus. If you remove the currently focused item or unmount the nav, manage any required recovery in your consumer. Without usable observation or Popover support, before enhancement and with no JavaScript, accepted links remain visible in a complete wrapped list. This native design is not a claim of universal browser or assistive technology certification.
Can I change the width, labels, colours or motion?
Use className for ordinary root width, padding and typography, supply distinct navLabel and moreLabel names, and localise allVisibleLabel for the temporary empty panel. Override the four priority-overflow colour properties through semantic aliases in the same ancestor scope. The chevron uses the CSS --dur-1 and --ease-out-quart tokens; reduced motion, forced colours and print keep it static. Link layout never animates, and the component adds no live announcement for responsive repartitioning. Close an open popover before animating an ancestor transform; there is no continuous tracking guarantee for an unsignalled moving ancestor.

Related components

Building the surrounding page?Explore Template Empire templates for complete page layouts. This free navigation widget can be composed with your own content; no particular paid template integration is implied.Browse Template Empire templates