# Navbar 5

> A documentation-site header with a collapsing secondary row, breadcrumb promotion, ⌘K search and version switching

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/navbar-5).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/navbar-5
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/navbar-5-light-ab001875421692b8.png
- Registry item: https://shadcnstore.com/r/navbar-5.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/navbar-5.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `badge`, `breadcrumb`, `button`, `command`, `dropdown-menu`, `kbd`, `select`, `separator`, `sheet`

## Files

### `storage/app/registry-sources/marketing/navbars/navbar-5/page.tsx`

```tsx
import { Navbar5 } from '@/components/blocks/marketing/navbars/navbar-5/components/navbar-5'

/**
 * Nav links, breadcrumb, versions and the search index live in `data/navbar-5-docs.ts`.
 *
 * The header is `sticky`, so `html { scroll-padding-top }` should clear it for in-page anchors —
 * same contract as `navbar-3`.
 *
 * The page below is deliberately a real documentation article rather than a spacer. Everything
 * this header does — the secondary row collapsing, the breadcrumb being promoted into the bar,
 * the search box shrinking to an icon — only happens once you are scrolled past the top, so a
 * placeholder page shows none of it and the block reads as a plain bar.
 */
export default function Page() {
  return (
    <div className="flex min-h-svh w-full flex-col">
      <Navbar5 />

      <main className="mx-auto w-full max-w-3xl flex-1 px-4 py-12 sm:px-6">
        <p className="text-muted-foreground font-mono text-xs tracking-[0.14em] uppercase">API · Production data</p>
        <h1 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">Production data</h1>
        <p className="text-muted-foreground mt-4 text-lg text-pretty">
          Read interval-level generation, consumption and export figures for any site on the account. Scroll and the
          header condenses: the section row folds away and the breadcrumb moves up into the bar.
        </p>

        <div className="mt-10 flex flex-col gap-10">
          {[
            {
              heading: 'Authentication',
              body: 'Every request is authenticated with a site-scoped key. Keys are issued per environment and never expire on their own — rotate them from the dashboard, and revoke the previous key once traffic has moved across.',
              code: 'curl https://api.solaris.dev/v3/sites/ste_8f21/production \\\n  -H "Authorization: Bearer sk_live_..."',
            },
            {
              heading: 'Intervals',
              body: 'Readings are returned at five-minute resolution for the last 90 days and hourly beyond that. Ask for a window wider than a year and the response is downsampled to daily totals rather than truncated.',
              code: 'GET /v3/sites/:id/production?from=2026-08-01&to=2026-08-17&resolution=hour',
            },
            {
              heading: 'Gaps and backfill',
              body: 'An inverter that loses connectivity leaves a gap rather than a zero, so a missing reading is never mistaken for a night. When the device reconnects it backfills, and the affected intervals are re-emitted on the webhook.',
              code: '{\n  "interval": "2026-08-14T11:05:00Z",\n  "watt_hours": null,\n  "state": "missing"\n}',
            },
            {
              heading: 'Rate limits',
              body: 'Sixty requests a minute per key, burst to a hundred. The remaining budget is on every response, and a 429 carries the seconds to wait — read it rather than backing off blindly.',
              code: 'X-RateLimit-Remaining: 41\nX-RateLimit-Reset: 27',
            },
          ].map(section => (
            <section key={section.heading} className="flex flex-col gap-3">
              <h2 className="text-xl font-semibold tracking-tight">{section.heading}</h2>
              <p className="text-muted-foreground text-pretty">{section.body}</p>
              <pre className="bg-muted/60 border-border overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
                {section.code}
              </pre>
            </section>
          ))}
        </div>
      </main>
    </div>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5.tsx`

```tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'motion/react'
import { Monitor, Moon, MoreHorizontal, Sun } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { Kbd } from '@/components/ui/kbd'
import {
  breadcrumb,
  docsPreviewSections,
  githubStarCount,
  primaryLinks,
  sectionLinks,
  versions,
} from "@/components/data/navbar-5-docs"
import { Navbar5Breadcrumb } from './navbar-5-breadcrumb'
import { Navbar5SearchTrigger } from './navbar-5-search-trigger'
import { Navbar5SidebarSheet } from './navbar-5-sidebar-sheet'
import { Navbar5VersionSwitcher } from './navbar-5-version-switcher'

/**
 * Composition contract
 * --------------------
 * The header is `sticky top-0`, so the page above it must reserve its height,
 * and `html { scroll-padding-top }` must clear it so in-page anchors do not
 * land underneath — same contract as `navbar-3`.
 *
 * This block is the reference implementation of the ShadcnStore logo rule
 * (`BLOCK-QUALITY-BAR.md` §5): path data from `resources/views/components/logo.blade.php`,
 * rendered `currentColor`, `text-primary`, `aria-hidden`, wordmark as text.
 */
function Logo() {
  return (
    <a href="#home" className="focus-visible:ring-ring flex items-center gap-2 rounded-sm focus-visible:ring-2 focus-visible:outline-none">
      <svg aria-hidden="true" viewBox="0 0 32 32" fill="none" className="text-primary size-6">
        <path
          d="M26 24.75C26.4142 24.75 26.75 24.4142 26.75 24C26.75 23.5858 26.4142 23.25 26 23.25V24.75ZM26 23.25H11V24.75H26V23.25ZM8.75 21V15H7.25V21H8.75ZM11 23.25C9.75736 23.25 8.75 22.2426 8.75 21H7.25C7.25 23.0711 8.92893 24.75 11 24.75V23.25Z"
          fill="currentColor"
        />
        <path
          d="M1.5 3.25C1.08579 3.25 0.75 3.58579 0.75 4C0.75 4.41421 1.08579 4.75 1.5 4.75V3.25ZM1.5 4.75H6V3.25H1.5V4.75ZM7.25 6V21H8.75V6H7.25ZM6 4.75C6.69036 4.75 7.25 5.30964 7.25 6H8.75C8.75 4.48122 7.51878 3.25 6 3.25V4.75Z"
          fill="currentColor"
        />
        <path
          d="M22 21.75C22.4142 21.75 22.75 21.4142 22.75 21C22.75 20.5858 22.4142 20.25 22 20.25V21.75ZM22 20.25H11V21.75H22V20.25ZM8.75 18V12H7.25V18H8.75ZM11 20.25C9.75736 20.25 8.75 19.2426 8.75 18H7.25C7.25 20.0711 8.92893 21.75 11 21.75V20.25Z"
          fill="currentColor"
        />
        <path
          d="M27.2057 19.754C27.0654 20.1438 26.6357 20.346 26.246 20.2057C25.8562 20.0654 25.654 19.6357 25.7943 19.246L27.2057 19.754ZM30.0361 9.67744L29.3305 9.4234L29.3305 9.4234L30.0361 9.67744ZM25.7943 19.246L29.3305 9.4234L30.7418 9.93148L27.2057 19.754L25.7943 19.246ZM28.1543 7.75L8 7.75V6.25L28.1543 6.25V7.75ZM29.3305 9.4234C29.6237 8.60882 29.0201 7.75 28.1543 7.75V6.25C30.059 6.25 31.3869 8.13941 30.7418 9.93148L29.3305 9.4234Z"
          fill="currentColor"
        />
        <path
          d="M13.5 21.75C13.0858 21.75 12.75 21.4142 12.75 21C12.75 20.5858 13.0858 20.25 13.5 20.25V21.75ZM26.7111 19.009L27.4174 19.2613L27.4174 19.2613L26.7111 19.009ZM13.5 20.25H23.8858V21.75H13.5V20.25ZM26.0048 18.7568L27.7937 13.7477L29.2063 14.2523L27.4174 19.2613L26.0048 18.7568ZM23.8858 20.25C24.8367 20.25 25.6849 19.6522 26.0048 18.7568L27.4174 19.2613C26.8843 20.7537 25.4706 21.75 23.8858 21.75V20.25Z"
          fill="currentColor"
        />
        <path d="M21.1694 10.5806L14.5651 17.1849" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        <path d="M22.1694 14.5806L18.5632 18.1868" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        <circle cx="13.1" cy="26.1" r="1.7" stroke="currentColor" strokeWidth="1.2" />
        <circle cx="22.1" cy="26.1" r="1.7" stroke="currentColor" strokeWidth="1.2" />
      </svg>
      <span className="text-base font-semibold tracking-tight">ShadcnStore</span>
    </a>
  )
}

const themeModes = [
  { value: 'light', label: 'Light', icon: Sun },
  { value: 'dark', label: 'Dark', icon: Moon },
  { value: 'system', label: 'System', icon: Monitor },
] as const

type ThemeMode = (typeof themeModes)[number]['value']

/**
 * A menu rather than a cycling button. A single icon that rotates through three states gives no
 * way to see which one is active or to jump straight to the one you want — you have to click and
 * watch. The radio group states the current mode and lets you pick any other in one action.
 */
function ThemeMenu({ mode, onModeChange }: { mode: ThemeMode; onModeChange: (mode: ThemeMode) => void }) {
  const active = themeModes.find(entry => entry.value === mode) ?? themeModes[2]
  const Icon = active.icon

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={<Button variant='ghost' size='icon' aria-label={`Theme: ${active.label}. Change theme`} />}
      >
        <Icon />
      </DropdownMenuTrigger>
      <DropdownMenuContent align='end' className='min-w-36'>
        <DropdownMenuLabel>Theme</DropdownMenuLabel>
        <DropdownMenuRadioGroup value={mode} onValueChange={value => onModeChange(value as ThemeMode)}>
          {themeModes.map(entry => (
            <DropdownMenuRadioItem key={entry.value} value={entry.value}>
              <entry.icon />
              {entry.label}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

/**
 * GitHub's own mark, not a star glyph. A bare star beside a number reads as a rating — which is
 * what it looked like here — rather than as a repository's stargazers.
 *
 * A readout, not a link. This is a demo header inside a gallery, and the only place a real href
 * could point is our own repository — so pressing a control in someone's block preview navigated
 * them off the page they were evaluating. The count is information the header displays; a buyer
 * who wants it to lead somewhere wraps it in their own anchor.
 */
function GithubStars({ stars }: { stars: number }) {
  return (
    <span
      role='status'
      aria-label={`${stars.toLocaleString()} GitHub stars`}
      className='text-muted-foreground inline-flex h-8 items-center gap-1.5 px-2 text-sm font-medium'
    >
      <svg viewBox='0 0 16 16' aria-hidden='true' className='size-4' fill='currentColor'>
        <path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z' />
      </svg>
      <span aria-hidden='true'>{(stars / 1000).toFixed(1)}k</span>
    </span>
  )
}

export function Navbar5() {
  const reduceMotion = useReducedMotion()
  const sentinelRef = useRef<HTMLDivElement>(null)
  const [scrolled, setScrolled] = useState(false)
  // Owned here rather than inside each control, so the desktop switcher and the mobile menu are
  // two views of one state instead of two controls that disagree the moment either is used.
  const [themeMode, setThemeMode] = useState<ThemeMode>('system')
  const [version, setVersion] = useState(versions[0].value)

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (!sentinel) return

    let scrollParent: HTMLElement | null = null
    let ancestor: HTMLElement | null = sentinel.parentElement
    while (ancestor) {
      const { overflowY } = getComputedStyle(ancestor)
      if ((overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && ancestor.scrollHeight > ancestor.clientHeight + 1) {
        scrollParent = ancestor
        break
      }
      ancestor = ancestor.parentElement
    }

    if (scrollParent) {
      const scroller = scrollParent
      const update = () => setScrolled(scroller.scrollTop > 8)
      update()
      scroller.addEventListener('scroll', update, { passive: true })
      return () => scroller.removeEventListener('scroll', update)
    }

    const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting), { threshold: 0 })
    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [])

  return (
    // A fragment, not a wrapper. `position: sticky` is resolved against the nearest scrolling
    // ancestor but constrained by its own parent's box — and a wrapper that contains nothing but
    // the sentinel and the header is exactly as tall as the header, so the header scrolled away
    // the moment the page moved. The documented sticky contract was never actually true. Leaving
    // these as siblings puts the header directly in the consumer's flow, where it can stick.
    <>
      <div ref={sentinelRef} aria-hidden="true" className="pointer-events-none h-2" />

      <header className="bg-background/95 sticky top-0 z-40 border-b backdrop-blur">
        <div className="mx-auto flex h-14 max-w-7xl items-center gap-3 px-4 sm:px-6">
          <div className="sm:hidden">
            <Navbar5SidebarSheet
              themeToggle={<ThemeMenu mode={themeMode} onModeChange={setThemeMode} />}
              version={version}
              onVersionChange={setVersion}
            />
          </div>
          <Logo />
          <Badge variant="secondary" className="hidden sm:inline-flex">
            Docs
          </Badge>

          <nav aria-label="Main" className="ml-2 hidden items-center gap-1 md:flex">
            {primaryLinks.map(link => (
              <a
                key={link.href}
                href={link.href}
                aria-current={link.active ? 'page' : undefined}
                className={`rounded-md px-2 py-1.5 text-sm font-medium ${link.active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
              >
                {link.label}
              </a>
            ))}
          </nav>

          <div className="ml-auto flex items-center gap-2">
            <div className="hidden sm:block">
              <Navbar5SearchTrigger compact={scrolled} />
            </div>
            <div className="sm:hidden">
              <Navbar5SearchTrigger compact />
            </div>

            {scrolled ? (
              <div className="hidden md:block">
                <Navbar5Breadcrumb items={breadcrumb} />
              </div>
            ) : null}

            <div className="hidden items-center gap-1 md:flex">
              <Navbar5VersionSwitcher value={version} onValueChange={setVersion} />
              <Separator orientation="vertical" className="mx-1 h-5" />
              <GithubStars stars={githubStarCount} />
              <ThemeMenu mode={themeMode} onModeChange={setThemeMode} />
            </div>

            <div className="md:hidden">
              <DropdownMenu>
                <DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="More options" />}>
                  <MoreHorizontal />
                </DropdownMenuTrigger>
                {/*
                  Every row here does something. The previous version listed "Version: v3.2" and
                  "Theme" as inert items, which is worse than omitting them — a control that looks
                  live and does nothing reads as a broken page rather than a missing feature.
                */}
                <DropdownMenuContent align="end" className="min-w-44">
                  <DropdownMenuLabel>Version</DropdownMenuLabel>
                  <DropdownMenuRadioGroup value={version} onValueChange={setVersion}>
                    {versions.map(entry => (
                      <DropdownMenuRadioItem key={entry.value} value={entry.value}>
                        {entry.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>

                  <DropdownMenuSeparator />

                  <DropdownMenuLabel>Theme</DropdownMenuLabel>
                  <DropdownMenuRadioGroup value={themeMode} onValueChange={value => setThemeMode(value as ThemeMode)}>
                    {themeModes.map(entry => (
                      <DropdownMenuRadioItem key={entry.value} value={entry.value}>
                        <entry.icon />
                        {entry.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>

                  <DropdownMenuSeparator />

                  {/* A readout here too, for the same reason as the desktop one above. */}
                  <DropdownMenuLabel className="font-normal">
                    {`GitHub · ${(githubStarCount / 1000).toFixed(1)}k stars`}
                  </DropdownMenuLabel>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          </div>
        </div>

        <motion.div
          initial={false}
          animate={{ height: scrolled ? 0 : 'auto', opacity: scrolled ? 0 : 1 }}
          transition={{ duration: reduceMotion ? 0 : 0.24, ease: 'easeOut' }}
          className="overflow-hidden border-t"
        >
          <nav aria-label="Section" className="mx-auto flex max-w-7xl gap-4 overflow-x-auto px-4 py-2 sm:px-6">
            {sectionLinks.map(link => (
              <a
                key={link.href}
                href={link.href}
                aria-current={link.active ? 'page' : undefined}
                className={`shrink-0 text-sm ${link.active ? 'text-primary font-medium' : 'text-muted-foreground hover:text-foreground'}`}
              >
                {link.label}
              </a>
            ))}
          </nav>
        </motion.div>

        <div className="border-t px-4 py-1.5 sm:px-6 md:hidden">
          <Navbar5Breadcrumb items={breadcrumb} truncate />
        </div>
      </header>
    </>
  )
}

/**
 * Preview wrapper — not part of what you paste into your own page; import `Navbar5` for that.
 *
 * Everything this header does happens on scroll: the section row folds away, the breadcrumb is
 * promoted into the bar, and the search box shrinks to an icon. Rendered on its own the block
 * shows none of that and reads as a plain bar, which is exactly how it looked in the gallery
 * before this existed. Same shape as `navbar-3`: an internal scroller keeps the store iframe
 * compact while still letting the transition fire.
 *
 * The scrollport is deliberately generous. It has to be tall enough that the docs page under the
 * bar reads as a docs page while you scroll it, rather than as a two-line sliver; at 28rem and
 * 32rem the body copy was cut off mid-section, so both are up by 30%.
 */
export default function Navbar5Preview() {
  return (
    <div className='bg-background relative h-[36.5rem] overflow-y-auto overscroll-contain sm:h-[41.5rem]'>
      <Navbar5 />

      <main className='mx-auto w-full max-w-3xl px-4 py-10 sm:px-6'>
        <p className='text-muted-foreground font-mono text-xs tracking-[0.14em] uppercase'>API · Production data</p>
        <h1 className='mt-3 text-2xl font-semibold tracking-tight sm:text-3xl'>Production data</h1>
        <p className='text-muted-foreground mt-3 text-pretty'>
          Scroll inside this panel: the section row folds away, the breadcrumb moves up into the bar and the search box
          collapses to an icon. Press <Kbd>⌘K</Kbd> to open the palette.
        </p>

        <div className='mt-8 flex flex-col gap-8'>
          {docsPreviewSections.map(section => (
            <section key={section.heading} className='flex flex-col gap-3'>
              <h2 className='text-lg font-semibold tracking-tight'>{section.heading}</h2>
              <p className='text-muted-foreground text-sm text-pretty'>{section.body}</p>
              <pre className='bg-muted/60 border-border overflow-x-auto rounded-lg border p-3 font-mono text-xs leading-relaxed'>
                {section.code}
              </pre>
            </section>
          ))}
        </div>
      </main>
    </div>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5-breadcrumb.tsx`

```tsx
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'
import type { breadcrumb as breadcrumbData } from "@/components/data/navbar-5-docs"

export function Navbar5Breadcrumb({ items, truncate = false }: { items: typeof breadcrumbData; truncate?: boolean }) {
  const visibleItems = truncate && items.length > 2 ? [items[0], items[items.length - 1]] : items

  return (
    <Breadcrumb aria-label="Breadcrumb">
      <BreadcrumbList className="flex-nowrap text-xs">
        {visibleItems.map((item, index) => {
          const isLast = index === visibleItems.length - 1
          return (
            <span key={item.href} className="flex items-center gap-1.5">
              {truncate && index === 1 && items.length > 2 ? (
                <>
                  <BreadcrumbItem>
                    <span className="text-muted-foreground">…</span>
                  </BreadcrumbItem>
                  <BreadcrumbSeparator />
                </>
              ) : null}
              <BreadcrumbItem>
                {isLast ? <BreadcrumbPage className="truncate">{item.label}</BreadcrumbPage> : <BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>}
              </BreadcrumbItem>
              {!isLast ? <BreadcrumbSeparator /> : null}
            </span>
          )
        })}
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5-search-trigger.tsx`

```tsx
'use client'

import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookOpen, Clock, CornerDownLeft, FileCode2, Search, Terminal, Webhook } from 'lucide-react'

import { Button } from '@/components/ui/button'
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut,
} from '@/components/ui/command'
import { Kbd } from '@/components/ui/kbd'

import { searchIndex, type SearchIndexEntry } from "@/components/data/navbar-5-docs"

/** Section → icon. A palette that returns a flat list of identical rows is a list, not a palette. */
const sectionIcons: Record<string, typeof BookOpen> = {
  Guides: BookOpen,
  API: Webhook,
  SDKs: Terminal,
  Changelog: FileCode2,
}

/**
 * `navigator.platform` is deprecated and absent in some engines, so the userAgent is the
 * fallback rather than the other way round.
 */
function isMacPlatform(): boolean {
  if (typeof navigator === 'undefined') {
    return false
  }

  return /mac/i.test(navigator.platform || navigator.userAgent)
}

function groupBySection(entries: SearchIndexEntry[]): Array<[string, SearchIndexEntry[]]> {
  const groups = new Map<string, SearchIndexEntry[]>()

  for (const entry of entries) {
    const bucket = groups.get(entry.section) ?? []
    bucket.push(entry)
    groups.set(entry.section, bucket)
  }

  return Array.from(groups)
}

export function Navbar5SearchTrigger({ compact = false }: { compact?: boolean }) {
  const [open, setOpen] = useState(false)
  const [platformKey, setPlatformKey] = useState('Ctrl')
  /** Ids, most recent first. A palette without recents makes you retype the page you just left. */
  const [recentIds, setRecentIds] = useState<string[]>([])

  useEffect(() => {
    setPlatformKey(isMacPlatform() ? '⌘' : 'Ctrl')
  }, [])

  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
        event.preventDefault()
        setOpen(current => !current)
      }
    }

    document.addEventListener('keydown', onKeyDown)

    return () => document.removeEventListener('keydown', onKeyDown)
  }, [])

  const groups = useMemo(() => groupBySection(searchIndex), [])
  const recents = useMemo(
    () => recentIds.map(id => searchIndex.find(entry => entry.id === id)).filter(Boolean) as SearchIndexEntry[],
    [recentIds]
  )

  const handleSelect = useCallback((entry: SearchIndexEntry) => {
    setRecentIds(current => [entry.id, ...current.filter(id => id !== entry.id)].slice(0, 3))
    setOpen(false)
  }, [])

  const renderItem = (entry: SearchIndexEntry, keyPrefix: string) => {
    const Icon = sectionIcons[entry.section] ?? BookOpen

    return (
      <CommandItem
        key={`${keyPrefix}-${entry.id}`}
        // Section is in the value so "api" finds every API page, not only titles containing it.
        value={`${entry.title} ${entry.section}`}
        onSelect={() => handleSelect(entry)}
      >
        <Icon />
        <span className='flex-1 truncate'>{entry.title}</span>
        <CommandShortcut>{entry.section}</CommandShortcut>
      </CommandItem>
    )
  }

  return (
    <>
      {compact ? (
        <Button
          variant='outline'
          size='icon'
          aria-label={`Search the docs, ${platformKey}K`}
          onClick={() => setOpen(true)}
        >
          <Search />
        </Button>
      ) : (
        <Button
          variant='outline'
          onClick={() => setOpen(true)}
          aria-label={`Search the docs, ${platformKey}K`}
          className='text-muted-foreground w-60 justify-between font-normal'
        >
          <span className='flex items-center gap-2'>
            <Search data-icon='inline-start' aria-hidden='true' />
            Search docs…
          </span>
          <Kbd>{platformKey}K</Kbd>
        </Button>
      )}

      <CommandDialog
        open={open}
        onOpenChange={setOpen}
        title='Search docs'
        description='Search guides, API reference and SDKs'
      >
        <Command>
          <CommandInput placeholder='Search guides, API, SDKs…' />
          <CommandList>
            <CommandEmpty>No pages match that search.</CommandEmpty>

            {recents.length > 0 && (
              <>
                <CommandGroup heading='Recent'>
                  {recents.map(entry => (
                    <CommandItem
                      key={`recent-${entry.id}`}
                      value={`recent ${entry.title} ${entry.section}`}
                      onSelect={() => handleSelect(entry)}
                    >
                      <Clock />
                      <span className='flex-1 truncate'>{entry.title}</span>
                      <CommandShortcut>{entry.section}</CommandShortcut>
                    </CommandItem>
                  ))}
                </CommandGroup>
                <CommandSeparator />
              </>
            )}

            {groups.map(([section, entries]) => (
              <CommandGroup key={section} heading={section}>
                {entries.map(entry => renderItem(entry, 'all'))}
              </CommandGroup>
            ))}
          </CommandList>

          {/*
            The keyboard contract, stated. This is the section's whole positioning — a palette
            that supports arrow keys but never says so is indistinguishable from one that does not.
          */}
          <div className='text-muted-foreground flex items-center gap-4 border-t px-3 py-2 text-xs'>
            <span className='flex items-center gap-1.5'>
              <Kbd>↑</Kbd>
              <Kbd>↓</Kbd>
              navigate
            </span>
            <span className='flex items-center gap-1.5'>
              <Kbd>
                <CornerDownLeft className='size-3' aria-hidden='true' />
              </Kbd>
              open
            </span>
            <span className='ml-auto flex items-center gap-1.5'>
              <Kbd>esc</Kbd>
              close
            </span>
          </div>
        </Command>
      </CommandDialog>
    </>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5-sidebar-sheet.tsx`

```tsx
'use client'

import type { ReactNode } from 'react'
import { Menu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { primaryLinks, sectionLinks } from "@/components/data/navbar-5-docs"
import { Navbar5VersionSwitcher } from './navbar-5-version-switcher'

export function Navbar5SidebarSheet({
  themeToggle,
  version,
  onVersionChange,
}: {
  themeToggle: ReactNode
  version: string
  onVersionChange: (value: string) => void
}) {
  return (
    <Sheet>
      <SheetTrigger
        render={
          <Button variant="ghost" size="icon" aria-label="Open docs navigation">
            <Menu className="size-5" />
          </Button>
        }
      />
      <SheetContent side="left" className="flex flex-col">
        <SheetHeader>
          <SheetTitle>Documentation</SheetTitle>
        </SheetHeader>
        <nav aria-label="Docs" className="flex flex-1 flex-col gap-1 overflow-y-auto px-4">
          {primaryLinks.map(link => (
            <SheetClose key={link.href} render={<a href={link.href} aria-current={link.active ? 'page' : undefined} />} className="hover:bg-accent rounded-md px-2 py-2 text-sm font-medium">
              {link.label}
            </SheetClose>
          ))}
          <Separator className="my-2" />
          {sectionLinks.map(link => (
            <SheetClose
              key={link.href}
              render={<a href={link.href} aria-current={link.active ? 'page' : undefined} />}
              className="text-muted-foreground hover:bg-accent rounded-md px-2 py-1.5 text-sm"
            >
              {link.label}
            </SheetClose>
          ))}
        </nav>
        <div className="flex items-center justify-between border-t px-4 py-3">
          <Navbar5VersionSwitcher value={version} onValueChange={onVersionChange} />
          {themeToggle}
        </div>
      </SheetContent>
    </Sheet>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5-version-switcher.tsx`

```tsx
'use client'

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

import { versions } from "@/components/data/navbar-5-docs"

/**
 * Controlled by the header rather than holding its own state, so this and the mobile menu are two
 * views of one selection instead of two switchers that disagree the moment either is used.
 */
export function Navbar5VersionSwitcher({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  const selected = versions.find(entry => entry.value === value)
  // Only the current release is guaranteed to have every page; say so rather than 404 later.
  const notice = selected && selected.value !== versions[0].value ? `This page may not exist in ${selected.label}.` : null

  return (
    <div className='relative flex flex-col gap-1'>
      <Select value={value} onValueChange={next => next && onValueChange(next)}>
        <SelectTrigger size='sm' className='w-28 font-mono' aria-label='Documentation version'>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {versions.map(entry => (
            <SelectItem key={entry.value} value={entry.value}>
              <span className='font-mono'>{entry.label}</span>
              <span className='text-muted-foreground ml-2 text-xs'>{entry.status}</span>
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      {notice ? (
        <p role='status' className='text-muted-foreground absolute top-full right-0 mt-1 w-max text-xs'>
          {notice}
        </p>
      ) : null}
    </div>
  )
}
```

### `storage/app/registry-sources/marketing/navbars/navbar-5/navbar-5-docs.ts`

```ts
/** Synthetic Solaris (residential solar & home-energy platform) demo data. */

export type PrimaryLink = { label: string; href: string; active?: boolean }

export const primaryLinks: PrimaryLink[] = [
  { label: 'Guides', href: '#guides', active: true },
  { label: 'API', href: '#api' },
  { label: 'SDKs', href: '#sdks' },
  { label: 'Changelog', href: '#changelog' },
]

export type SectionLink = { label: string; href: string; active?: boolean }

export const sectionLinks: SectionLink[] = [
  { label: 'Getting started', href: '#getting-started' },
  { label: 'Authentication', href: '#authentication' },
  { label: 'Sites', href: '#sites' },
  { label: 'Production data', href: '#production-data', active: true },
  { label: 'Webhooks', href: '#webhooks' },
]

export const breadcrumb = [
  { label: 'Docs', href: '#docs' },
  { label: 'API', href: '#api' },
  { label: 'Production data', href: '#production-data' },
]

export type DocsVersion = { value: string; label: string; status: 'Current' | 'Maintenance' | 'Preview' }

export const versions: DocsVersion[] = [
  { value: 'v3.2', label: 'v3.2', status: 'Current' },
  { value: 'v3.1', label: 'v3.1', status: 'Maintenance' },
  { value: 'v3.0', label: 'v3.0', status: 'Maintenance' },
  { value: 'next', label: 'Next', status: 'Preview' },
]

/** A real fetch point would replace this static figure — comment marks where. */
export const githubStarCount = 4100

export type SearchIndexEntry = { id: string; title: string; section: string; href: string }

export const searchIndex: SearchIndexEntry[] = [
  { id: 'getting-started', title: 'Getting started', section: 'Guides', href: '#getting-started' },
  { id: 'authentication', title: 'Authentication', section: 'API', href: '#authentication' },
  { id: 'sites', title: 'Sites', section: 'API', href: '#sites' },
  { id: 'production-data', title: 'Production data', section: 'API', href: '#production-data' },
  { id: 'webhooks', title: 'Webhooks', section: 'API', href: '#webhooks' },
  { id: 'js-sdk', title: 'JavaScript SDK', section: 'SDKs', href: '#sdks' },
  { id: 'python-sdk', title: 'Python SDK', section: 'SDKs', href: '#sdks' },
  { id: 'changelog', title: 'Changelog', section: 'Changelog', href: '#changelog' },
]

/** Body copy for the preview wrapper only — not shipped as part of the header itself. */
export const docsPreviewSections = [
  {
    heading: 'Authentication',
    body: 'Every request is authenticated with a site-scoped key. Keys are issued per environment and never expire on their own — rotate from the dashboard, then revoke the old key once traffic has moved.',
    code: 'curl https://api.solaris.dev/v3/sites/ste_8f21/production \\\n  -H "Authorization: Bearer sk_live_..."',
  },
  {
    heading: 'Intervals',
    body: 'Readings come back at five-minute resolution for the last 90 days and hourly beyond that. Ask for more than a year and the response is downsampled to daily totals rather than truncated.',
    code: 'GET /v3/sites/:id/production?from=2026-08-01&to=2026-08-17&resolution=hour',
  },
  {
    heading: 'Gaps and backfill',
    body: 'An inverter that loses connectivity leaves a gap rather than a zero, so a missing reading is never mistaken for a night. On reconnect it backfills and the affected intervals are re-emitted on the webhook.',
    code: '{\n  "interval": "2026-08-14T11:05:00Z",\n  "watt_hours": null,\n  "state": "missing"\n}',
  },
  {
    heading: 'Rate limits',
    body: 'Sixty requests a minute per key, bursting to a hundred. The remaining budget is on every response and a 429 carries the seconds to wait — read it rather than backing off blindly.',
    code: 'X-RateLimit-Remaining: 41\nX-RateLimit-Reset: 27',
  },
]
```
