Sidebar
The app navigation rail — Pho’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 Pho 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.insetisbase(default) orsm(tighter padding / header spacing) — prefer that over host-app class overrides. - Header — brand slot at the top (logo, wordmark, workspace switcher).
Spacing follows
Root’sinset. - Nav — the scrollable middle; a stack of groups.
- Group / GroupLabel / List — one titled cluster of rows.
- Item — a nav row: a link when
hrefis given, otherwise a button. - 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).
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 hostLinkProvider. Omit it to get a<button>row (wireonClick). - icon —
FC | ReactElement— slot before the label (text-pho-secondary) - active —
boolean— current destination; addsaria-current="page" - external —
boolean— new tab + ↗ cue (links only)
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 —
Rootis an<aside>andNava<nav>; giveNavanaria-labelwhen a page has more than one navigation region. - Current page —
activesetsaria-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. - 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
activefrom your router, fetch the account outside, and pass results down as props. - Route SPA links through
LinkProvideronce at the app root — the same providerButtonLinkuses — so rail navigation stays client-side. - Put project/workspace switching in
Headerand the account row inFooter— the rail’s top and bottom are its two anchor points. - Don’t stack two interactive controls in one row; compose a
MenuaroundAccountRowinstead of adding a second button inside it.