Photon Design System

Sidebar

The app navigation rail — Photon’s first section: a composed assembly built from the micro components, not another primitive. It encodes the rail used across Photon surfaces (this docs site, the dashboard): a sticky full-height column with a brand header, a scrollable stack of titled nav groups, and a pinned footer that usually carries the account row.

Sections stay presentational. No router, no data fetching, no theme plumbing — links render through the host’s LinkProvider (the same contract ButtonLink uses), the current page arrives as an active prop, and interactive parts compose with other Photon components (the account row slots into a Menu.Trigger).

Import

import { Sidebar } from "@photon-ai/pho-ui/sections/sidebar";

Anatomy

<Sidebar.Root>
  <Sidebar.Header>
    <PhotonMark className="text-pho-brand size-6" />
    <span className="font-medium tracking-tight">Photon</span>
  </Sidebar.Header>

  <Sidebar.Nav>
    <Sidebar.Group>
      <Sidebar.GroupLabel>Pages</Sidebar.GroupLabel>
      <Sidebar.List>
        <Sidebar.Item href="/" icon={IconLayoutDashboard} active>
          Overview
        </Sidebar.Item>
        <Sidebar.Item href="/members" icon={IconUsers}>
          Members
        </Sidebar.Item>
      </Sidebar.List>
    </Sidebar.Group>
  </Sidebar.Nav>

  <Sidebar.Footer>
    <Sidebar.List>
      <Sidebar.Item icon={IconBook} href="https://photon.codes/docs/" external>
        Docs
      </Sidebar.Item>
    </Sidebar.List>

    <Menu.Root>
      <Menu.Trigger render={<Sidebar.AccountRow name="Ada Lovelace" />} />
      <Menu.Portal>{/* account menu */}</Menu.Portal>
    </Menu.Root>
  </Sidebar.Footer>
</Sidebar.Root>
  • Root — the rail: a sticky, full-height <aside> with the hairline right edge, on the page surface. inset is base (default) or sm (tighter padding / header spacing) — prefer that over host-app class overrides.
  • Header — brand slot at the top (logo, wordmark, workspace switcher). Spacing follows Root’s inset.
  • Search — the rail’s search row: Item metrics, ⌘K at the trailing edge; wire onClick to a Command.Dialog.
  • Nav — the scrollable middle; a stack of groups in a ScrollArea (overlay scrollbar, edge fade). scroll={false} makes it a plain stack at its natural height, for a parent that scrolls the whole rail as one column (a narrow-viewport drawer). The nav owns its breathing room toward its neighbors as a resting inset — 24px above, 16px below by default: rows stand clear at rest and scroll through the gap under the edge fade, instead of clipping at a dead margin on the neighbor. insetTop / insetBottom (or inset for both) override it; the values become plain padding when scroll={false}. A Header right before the nav and a Footer after it drop their own margins — the gap lives on the nav.
  • Group / GroupLabel / List — one titled cluster of rows.
  • Item — a nav row: a link when href is given, otherwise a button.
  • Submenu — a row that folds open to reveal child Items.
  • Footer — pinned bottom cluster (docs link, account row).
  • AccountRow — the whole-row account trigger for a Menu.

Items

Sidebar.Item renders a link through the host LinkProvider when href is given, and a plain button otherwise. active lifts the row onto the primary surface with the site hairline and sets aria-current="page"; external opens a new tab and appends a ↗ cue (with a screen-reader note).

Sidebar.Submenu is a row that holds rows. The trigger wears Item metrics with a trailing chevron that turns as the panel opens; the children are ordinary Sidebar.Items (no icon needed), indented so their labels line up with the parent’s label. The fold is the library’s Collapsible — one height animation, collapsed under reduced motion — and a closed group adds no height, so siblings never shift.

Children report active upward: while one is current the parent takes primary ink (label and icon, no surface — the child carries the chip), and with openWhenChildActive (the default) the group opens on load without animating, and again whenever a different child becomes current. The user can still collapse it.

<Sidebar.List>
  <Sidebar.Item href="/" icon={IconLayoutDashboard}>
    Overview
  </Sidebar.Item>
  <Sidebar.Submenu label="Platforms" icon={IconMessage}>
    <Sidebar.Item href="/platforms/sms">SMS</Sidebar.Item>
    <Sidebar.Item href="/platforms/imessage" active>
      iMessage
    </Sidebar.Item>
    <Sidebar.Item href="/platforms/email">Email</Sidebar.Item>
  </Sidebar.Submenu>
  <Sidebar.Item href="/webhooks" icon={IconWebhook}>
    Webhooks
  </Sidebar.Item>
</Sidebar.List>

Leave it uncontrolled (defaultOpen) in a route-based rail — the active child does the opening. Pass open + onOpenChange to own the state, for instance to persist collapsed groups; the auto-open then arrives as an onOpenChange(true) for the host to apply.

Columns

For a long, flat registry — like this site’s component list — List lays rows out in a two-column grid with columns.

<Sidebar.List columns>
  <Sidebar.Item href="/components/button">Button</Sidebar.Item>
  <Sidebar.Item href="/components/input">Input</Sidebar.Item>
  {/* … */}
</Sidebar.List>

Account row

Sidebar.AccountRow is ONE button — the ⋯ circle on the right is decorative (a span wearing the stock secondary-circle button classes). The row is the control, the circle is the indicator: hovering or pressing anywhere on the row lifts and squeezes the ⋯ circle (driven by the row’s group state) while the row itself stays quiet, so the whole chip reads as a single control without nesting buttons. It forwards every prop, so it slots straight into Base UI’s render prop:

<Menu.Root>
  <Menu.Trigger
    render={<Sidebar.AccountRow name="Ada Lovelace" avatarSrc={pictureUrl} />}
  />
  <Menu.Portal>
    <Menu.Positioner side="top" align="end">
      <Menu.Popup>{/* settings / theme / log out */}</Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

data-popup-open and data-pressed from the trigger drive the ⋯ circle: it stays lifted while the menu is open and scales down slightly on press — the row keeps only its keyboard focus ring; hover, press, and menu-open feedback all land on the circle.

Props

Sidebar.Item

  • href — string — destination; rendered through the host LinkProvider. Omit it to get a <button> row (wire onClick).
  • icon — FC | ReactElement — slot before the label (text-pho-secondary)
  • active — boolean — current destination; adds aria-current="page"
  • external — boolean — new tab + ↗ cue (links only)
  • wrap — boolean — let the label break onto two lines (text-balance, capped at two) instead of truncating. Default rows stay one line.

Sidebar.Submenu

  • label — ReactNode — the parent row’s label
  • icon — FC | ReactElement — slot before the label; children indent to the label’s edge (with or without it)
  • open / onOpenChange — boolean / (open: boolean) => void — controlled open state; onOpenChange also fires for auto-opens
  • defaultOpen — boolean — initial state when uncontrolled (false)
  • openWhenChildActive — boolean — open when a child row becomes active (true)
  • active — boolean — force the child-active ink when the current destination sits under the group without a row of its own
  • children — the nested Sidebar.Items
  • …also accepts any native <button> attribute (lands on the trigger row)

Sidebar.List

  • columns — boolean — two-column grid instead of a single stack

Sidebar.AccountRow

  • name — string — display name; also the source for fallback initials
  • avatarSrc — string — avatar image URL
  • initials — string — override the derived initials
  • …also accepts any native <button> attribute

Every part merges className onto its root and forwards native attributes.

Accessibility

  • Landmarks — Root is an <aside> and Nav a <nav>; give Nav an aria-label when a page has more than one navigation region.
  • Current page — active sets aria-current="page" on the row, so the selected state is announced, not just painted.
  • External links — the ↗ cue is aria-hidden; a visually hidden “(opens in a new tab)” rides along for screen readers.
  • Submenus — the trigger is a native <button> with aria-expanded and aria-controls pointing at the panel; Enter and Space toggle it. Closed children stay mounted but hidden, so they are out of the tab order and the accessibility tree until the group opens. The chevron is decorative.
  • Account row — one real <button>; the ⋯ circle is decorative and hidden from assistive tech, so the chip is announced once, by name.

Best practices

  • Keep sections presentational: derive active from your router, fetch the account outside, and pass results down as props.
  • Route SPA links through LinkProvider once at the app root — the same provider ButtonLink uses — so rail navigation stays client-side.
  • Put project/workspace switching in Header and the account row in Footer — the rail’s top and bottom are its two anchor points.
  • Don’t stack two interactive controls in one row; compose a Menu around AccountRow instead of adding a second button inside it.