Full columns need container style queries, in Chrome 111, Safari 18 and
Firefox 151. Older browsers show the top pane, with the root beside it once
two columns fit.
value is controlled and comes from your router. An item is active on an
exact match, or on a value + '/' prefix for the pages under it. Normalise
the pathname first. /events/ misses the exact match and reads as a page
below /events.
Author the whole tree in a client component. Navigation's
server and client table says why and what can
stay on the server.
Navigator.Primary must be a direct child of Navigator. Items must be
direct children of Primary, a Group or a Secondary. A mapped array
counts as direct. A Fragment or your own component does not, and anything
inside it is skipped.
Everything else you pass to Navigator is its content, and Navigator renders
it in a <main>. Panes from parallel-route slots are more children like any
other.
Splitting a big tree
An app with several rails is tempting to split into components, but moving
each rail into its own UserRail component hides Navigator.Primary
from the walk. The bar still draws, but secondary navs, More rows and
useNavigatorSecondary do nothing. Write each rail as a function you call, not
a component you render, and call any hook it needs in the surrounding
component.
<Navigatorvalue={pathname}>
{kind ==='org'?orgRail(rail):userRail(rail)}
{children}
</Navigator>
The same works one level down, such as a function that returns a mapped array
of Navigator.MenuItems. Navigation builds a full
shell this way.
Examples
Narrow your viewport to see the tab bar and the stacked panes. Press Full width
on an example with a secondary nav to see its columns.
Default
A few destinations and a pinned Account item.
functionConsumerNav(){const[active, setActive]=useState('discover')const labels ={ discover:'Discover', tickets:'My tickets', saved:'Saved', account:'Account'}const upcoming =[{ title:'Midnight Frequency', description:'Sat 12 Jul · The Longacre'},{ title:'Sunset Sounds', description:'Fri 18 Jul · Meridian Stage'},{ title:'Neon Dusk', description:'Sat 26 Jul · The Burrow'},{ title:'Harbour Lights', description:'Sun 3 Aug · The Signal Hall'},{ title:'Analog Bloom', description:'Fri 15 Aug · The Anchorage'},{ title:'Coastal Echo', description:'Sat 23 Aug · The Palisade Theatre'},{ title:'Velvet Static', description:'Fri 5 Sep · The Lowlight'},{ title:'Golden Hour', description:'Sat 20 Sep · Beacon Piazza'}]return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><Navigatorvalue={active}onValueChange={setActive}className='h-full'><Navigator.Primaryaria-label='Main'><Navigator.Brand/><Navigator.Itemvalue='discover'icon={<MagnifyingGlassIcon/>}> Discover</Navigator.Item><Navigator.Itemvalue='tickets'icon={<TicketIcon/>}> My tickets</Navigator.Item><Navigator.Itemvalue='saved'icon={<HeartIcon/>}> Saved</Navigator.Item><Navigator.Itemvalue='account'icon={<GearIcon/>}placement='pinned'> Account</Navigator.Item></Navigator.Primary><Pane><divclassName='grid gap-3 py-4'><h2className='text-display-ui-4 text-strong'>{labels[active]}</h2><List>{upcoming.map((event)=>(<List.Itemkey={event.title}title={event.title}description={event.description}leading={<IconTileintent='accent'emphasis='subtle'><TicketIconweight='bold'/></IconTile>}chevron/>))}</List></div></Pane></Navigator></div>)}render(<ConsumerNav/>)
Groups
Each Navigator.Group is its own capsule, named by its GroupTitle. Loose
items next to each other share one capsule. Titles show when the vertical
navigation is expanded, and in More.
functionGroupedNav(){const[active, setActive]=useState('overview')const labels ={ overview:'Overview', events:'Events', orders:'Orders', reviews:'Reviews', messages:'Messages'}return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><Navigatorvalue={active}onValueChange={setActive}className='h-full'><Navigator.Primaryaria-label='Admin'><Navigator.Brand/><Navigator.Itemvalue='overview'icon={<MagnifyingGlassIcon/>}> Overview</Navigator.Item><Navigator.Group><Navigator.GroupTitle>Manage</Navigator.GroupTitle><Navigator.Itemvalue='events'icon={<TicketIcon/>}> Events</Navigator.Item><Navigator.Itemvalue='orders'icon={<ShoppingCartIcon/>}> Orders</Navigator.Item></Navigator.Group><Navigator.Group><Navigator.GroupTitle>Insights</Navigator.GroupTitle><Navigator.Itemvalue='reviews'icon={<StarIcon/>}> Reviews</Navigator.Item><Navigator.Itemvalue='messages'icon={<EnvelopeIcon/>}> Messages</Navigator.Item></Navigator.Group></Navigator.Primary><Pane><divclassName='grid gap-3 py-4'><h2className='text-display-ui-4 text-strong'>{labels[active]}</h2><pclassName='text-subtle'> Three capsules: Overview on its own, then Manage and Insights.</p></div></Pane></Navigator></div>)}render(<GroupedNav/>)
Secondary nav
Give an item an href and a Navigator.Secondary to give that destination
pages of its own. Declaring Navigator.Secondary makes Navigator draw the
destination's list pane for you, titled with the item's label, with its groups
and the current row marked. You don't render it. Your code renders only the
page's pane.
On the destination's own route, the list sits beside your page where two
columns fit, and covers it when panes stack.
On one of its pages, the list stays beside the page where two columns fit.
When panes stack, the page covers the list and shows Back to the
destination's route.
searchable adds a search field to the list pane. It filters rows by label and
hides empty groups. Cancel or Escape clears the search and leaves the field.
functionSearchableNav(){return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><DemoRouterinitialPath='/events'>{(path)=>(<Navigatorvalue={path}className='h-full'><Navigator.Primaryaria-label='Organiser'><Navigator.Brandhref='/events'/><Navigator.Itemvalue='/events'href='/events'icon={<TicketIcon/>}> Events<Navigator.Secondaryaria-label='Events pages'searchable><Navigator.Group><Navigator.GroupTitle>Selling</Navigator.GroupTitle><Navigator.Itemvalue='/events/upcoming'href='/events/upcoming'> Upcoming</Navigator.Item><Navigator.Itemvalue='/events/on-sale'href='/events/on-sale'> On sale</Navigator.Item><Navigator.Itemvalue='/events/sold-out'href='/events/sold-out'> Sold out</Navigator.Item></Navigator.Group><Navigator.Group><Navigator.GroupTitle>Archive</Navigator.GroupTitle><Navigator.Itemvalue='/events/past'href='/events/past'> Past</Navigator.Item><Navigator.Itemvalue='/events/drafts'href='/events/drafts'> Drafts</Navigator.Item></Navigator.Group></Navigator.Secondary></Navigator.Item><Navigator.Itemvalue='/marketing'href='/marketing'icon={<EnvelopeIcon/>}> Marketing</Navigator.Item></Navigator.Primary><Pane><Pane.Header><Pane.Title>{path}</Pane.Title></Pane.Header><pclassName='pb-4 text-subtle'>Type in the list's search field.</p></Pane></Navigator>)}</DemoRouter></div>)}render(<SearchableNav/>)
Overview page
overview on Navigator.Secondary shows the destination's own route alone,
full width, with no list pane and no Back. Its pages work as they do without
it. Use it when the destination has a page worth reading, such as Home or a
dashboard.
That page then offers the destination's pages in its body.
Navigator.SecondaryItems renders them as a grouped List, the same rows as
the list pane, with each item's description under its label.
functionOverviewNav(){const titles ={'/':'Home','/overview/installation':'Installation','/overview/philosophy':'Philosophy','/components':'Components'}return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><DemoRouterinitialPath='/'>{(path)=>(<Navigatorvalue={path}className='h-full'><Navigator.Primaryaria-label='Documentation'><Navigator.Brand/><Navigator.Itemvalue='/'href='/'icon={<HouseIcon/>}> Home<Navigator.Secondaryaria-label='Home pages'overview><Navigator.Itemvalue='/overview/installation'href='/overview/installation'description='Install the packages and wire up the CSS.'> Installation</Navigator.Item><Navigator.Itemvalue='/overview/philosophy'href='/overview/philosophy'description='The goals and the mental model.'> Philosophy</Navigator.Item></Navigator.Secondary></Navigator.Item><Navigator.Itemvalue='/components'href='/components'icon={<CubeIcon/>}> Components</Navigator.Item></Navigator.Primary><Pane><Pane.Header><Pane.Title>{titles[path]}</Pane.Title></Pane.Header>{path ==='/'?(<Navigator.SecondaryItems/>):(<pclassName='pb-4 text-subtle'>The page at {path}.</p>)}</Pane></Navigator>)}</DemoRouter></div>)}render(<OverviewNav/>)
overview needs the item's href. Without one, Navigator warns and shows
the list.
On a phone, tapping the destination's tab on one of its pages goes to its
route. On the route itself, it scrolls the page to the top.
showList still shows the list over one of its pages. It does nothing on
the destination's own route.
value has to come from the route that renders the page, as the
shell passes it, or the list pane and Back read
the wrong route.
Navigator.SecondaryPane is the rare escape hatch. It replaces one
destination's generated list pane with yours, such as to put a card above the
rows. Declare it before your detail pane.
<Navigator.SecondaryItems showDescriptions={false} /> renders the same rows,
and its query prop lets your pane keep search.
Anything past five slots. With too many items, the top four stay beside More, or three beside a pinned circle. Extra pinned items always fold.
Large screen, collapsed
Whatever doesn't fit the window's height.
Large screen, expanded
Nothing. The cluster scrolls instead.
visibilityPriority on an item or a group decides which items stay: 'high',
'automatic' or 'low'. An item's own priority wins over its group's. Ties go
to source order, and the items that stay keep their source order.
Here Orders and Messages are 'high', so they keep their place on the phone
bar. On a large screen all eight fit, and Help, at 'low', would fold first in
a shorter window.
functionPriorityNav(){const[active, setActive]=useState('discover')return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><Navigatorvalue={active}onValueChange={setActive}className='h-full'><Navigator.Primaryaria-label='Main'><Navigator.Brand/><Navigator.Itemvalue='discover'icon={<MagnifyingGlassIcon/>}> Discover</Navigator.Item><Navigator.Itemvalue='tickets'icon={<TicketIcon/>}> Tickets</Navigator.Item><Navigator.Itemvalue='saved'icon={<HeartIcon/>}> Saved</Navigator.Item><Navigator.Itemvalue='orders'icon={<ShoppingCartIcon/>}visibilityPriority='high'> Orders</Navigator.Item><Navigator.Itemvalue='reviews'icon={<StarIcon/>}> Reviews</Navigator.Item><Navigator.Itemvalue='messages'icon={<EnvelopeIcon/>}visibilityPriority='high'> Messages</Navigator.Item><Navigator.Itemvalue='downloads'icon={<DownloadIcon/>}> Downloads</Navigator.Item><Navigator.Itemvalue='help'icon={<InfoIcon/>}visibilityPriority='low'> Help</Navigator.Item></Navigator.Primary><Pane><divclassName='grid gap-3 py-4'><h2className='text-display-ui-4 text-strong'>{active}</h2><pclassName='text-subtle'> Eight items. Orders and Messages are high priority, Help is low.</p></div></Pane></Navigator></div>)}render(<PriorityNav/>)
More opens as a list pane. It covers the stack on a phone, and takes the
leading column where two columns fit.
Tapping More again scrolls it to the top, like an active tab.
Choosing a destination closes More, and so does a new value from your
router.
A row with an href keeps More open until value changes, so the old page
never shows while the route loads.
Menus
Navigator.Menu gives an item a menu instead of a destination, such as an
account menu. Navigator.MenuItem takes an href or an onSelect, plus an
optional icon and description.
functionMenuNav(){const[signedOut, setSignedOut]=useState(false)return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><DemoRouterinitialPath='/discover'>{(path)=>(<Navigatorvalue={path}className='h-full'><Navigator.Primaryaria-label='Main'><Navigator.Brandhref='/discover'/><Navigator.Itemvalue='/discover'href='/discover'icon={<MagnifyingGlassIcon/>}> Discover</Navigator.Item><Navigator.Itemvalue='/tickets'href='/tickets'icon={<TicketIcon/>}> Tickets</Navigator.Item><Navigator.Itemvalue='account'icon={<GearIcon/>}placement='pinned'> Account<Navigator.Menu><Navigator.MenuItemhref='/profile'icon={<PencilSimpleIconweight='bold'/>}description='luke@example.com'> Profile</Navigator.MenuItem><Navigator.MenuItemhref='/notifications'icon={<BellRingingIconweight='bold'/>}> Notifications</Navigator.MenuItem><Navigator.MenuItemonSelect={()=>setSignedOut(true)}icon={<XIconweight='bold'/>}> Sign out</Navigator.MenuItem></Navigator.Menu></Navigator.Item></Navigator.Primary><Pane><divclassName='grid gap-3 py-4'><h2className='text-display-ui-4 text-strong'>{path}</h2><pclassName='text-subtle'>{signedOut ?'Signed out.':'Open Account to see its menu.'}</p></div></Pane></Navigator>)}</DemoRouter></div>)}render(<MenuNav/>)
The menu opens beside the tile or row on a large screen, above the tab on a
phone, and below the row in More.
The item never navigates. It reads as active only while its menu is open.
Badges
Pass a Badge as badge. It shrinks to a dot while collapsed and on the phone
bar, and trails the label at size='sm' when expanded. More and list pane rows
show it as declared. Write the full meaning, such as "3 unread", because screen
readers announce it.
functionBadgeNav(){const[active, setActive]=useState('/home')return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><Navigatorvalue={active}onValueChange={setActive}className='h-full'><Navigator.Primaryaria-label='Main'><Navigator.Brand/><Navigator.Itemvalue='/home'icon={<MagnifyingGlassIcon/>}> Home</Navigator.Item><Navigator.Itemvalue='/inbox'icon={<EnvelopeIcon/>}badge={<Badgeintent='danger'emphasis='strong'>3 unread</Badge>}> Inbox</Navigator.Item><Navigator.ExpandToggle/></Navigator.Primary><Pane><pclassName='py-4 text-subtle'>Expand the navigation to see the full badge.</p></Pane></Navigator></div>)}render(<BadgeNav/>)
Expanded
expanded, defaultExpanded and onExpandedChange widen the vertical
navigation to show labels beside the icons. Navigator.ExpandToggle is the
built-in control, and it sits with the brand wherever you write it. Phones have
no expanded state.
functionExpandedNav(){const[active, setActive]=useState('discover')const[expanded, setExpanded]=useState(true)return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><Navigatorvalue={active}onValueChange={setActive}expanded={expanded}onExpandedChange={setExpanded}className='h-full'><Navigator.Primaryaria-label='Main'><Navigator.Brand/><Navigator.Group><Navigator.GroupTitle>Browse</Navigator.GroupTitle><Navigator.Itemvalue='discover'icon={<MagnifyingGlassIcon/>}> Discover</Navigator.Item><Navigator.Itemvalue='tickets'icon={<TicketIcon/>}> My tickets</Navigator.Item><Navigator.Itemvalue='saved'icon={<HeartIcon/>}> Saved</Navigator.Item></Navigator.Group><Navigator.Itemvalue='account'icon={<GearIcon/>}placement='pinned'> Account</Navigator.Item><Navigator.ExpandToggle/></Navigator.Primary><Pane><divclassName='grid gap-3 py-4'><h2className='text-display-ui-4 text-strong'>{expanded ?'Expanded':'Collapsed'}</h2><pclassName='text-subtle'>Toggle it from beside the brand.</p></div></Pane></Navigator></div>)}render(<ExpandedNav/>)
Remember the expanded state
Navigator never touches storage. Save the choice in a cookie. If you render on
the server, read the cookie there and pass defaultExpanded, so the first paint
is right with no script.
On a static site, add getNavigatorExpandedScript() from
@oztix/roadie-core/navigator to <head>, and set expandedFromDocument
instead of defaultExpanded.
Navigator.Brand links home from the top of the vertical navigation. It shows
on large screens only. With no children it renders the Oztix
Logo, the mark when collapsed and the wordmark when
expanded.
Your own brand goes in as children and names the link. The first child stays in
the icon column, and anything after it fades in once expanded. Add
hidden navigator-expanded:inline to hide a wordmark until then.
Roadie never reads the URL. To let a phone user open the destination's list
over one of its pages, keep a flag such as ?nav in the query string. Pass it
as showList and turn onShowListChange into a URL update. Tapping the active
destination's tab on one of its pages then shows the list instead of going to
the destination's route.
Here useState stands in for the query string, and navigating clears it as a
new URL would. Narrow your viewport and tap Events on one of its pages.
functionListFromUrlNav(){const[showList, setShowList]=useState(false)return(<divclassName='h-[30rem] overflow-hidden rounded-2xl border border-subtle'><DemoRouterinitialPath='/events/upcoming'onNavigate={()=>setShowList(false)}>{(path)=>(<Navigatorvalue={path}showList={showList}onShowListChange={setShowList}className='h-full'><Navigator.Primaryaria-label='Organiser'><Navigator.Brandhref='/events'/><Navigator.Itemvalue='/events'href='/events'icon={<TicketIcon/>}> Events<Navigator.Secondaryaria-label='Events pages'><Navigator.Itemvalue='/events/upcoming'href='/events/upcoming'> Upcoming</Navigator.Item><Navigator.Itemvalue='/events/past'href='/events/past'> Past</Navigator.Item></Navigator.Secondary></Navigator.Item><Navigator.Itemvalue='/marketing'href='/marketing'icon={<EnvelopeIcon/>}> Marketing</Navigator.Item></Navigator.Primary><Pane><Pane.Header><Pane.Title>{path}</Pane.Title></Pane.Header><pclassName='pb-4 text-subtle'>{showList ?'The list is showing (?nav).':'No ?nav in the URL.'}</p></Pane></Navigator>)}</DemoRouter></div>)}render(<ListFromUrlNav/>)
showMore and onShowMoreChange do the same for More. Without them, More keeps
its own state. With them, More asks through onShowMoreChange when someone:
taps More;
presses Escape;
chooses a row or menu that doesn't navigate.
It also asks when value changes while showMore is still true. A row or tab
that navigates doesn't ask, because its route closes More as it commits.
In Next.js,
Navigation shows
how to keep both flags in the query string.
Waiting for a navigation
After 150ms of a navigation with nothing on screen changed, the frame fills
with a slowly turning gradient of three Oztix colours, behind the panes and the
nav's capsules. On a phone the panes also pull back a little and round their
corners. The gradient fades once the destination arrives. A navigation faster
than 150ms shows nothing, so a prefetched route stays quiet.
RoadieLinkProvider starts it for a plain left click on the internal href of
any Roadie surface, and pendingIndicator={false} on the provider turns it
off. For a navigation you start yourself, such as a router.push, use
usePendingNavigation. Linking documents both.
For a wait Roadie can't see, pass pending on the
pane. A mounted Skeleton never starts or holds the indicator, because a
skeleton may be a placeholder that never resolves.
The indicator ends on the first of these:
the destination landing;
a Back or Forward traversal;
the tab going to the background;
a 10 second ceiling, which also catches a pane left with pending and logs it
in development.
A pane still reporting pending outlasts the first three, because a Back press
does not make its content arrive. Reduced motion keeps the colour and drops the
turn and the phone pull-back.
Guidelines
Derive value from the URL and give each item an href, so Back, forward and
deep links work, as the shell does. Use
onValueChange only when no route owns the state.
Write pinned items last. The vertical navigation always tabs through brand,
cluster, then pinned items, so source order should match.
Pass bare icons to items. Navigator renders them duotone at size-6. Give
Navigator.MenuItem icons weight='bold'. An item without an icon shows its
label's first letter.
An item links to its declared href, in More too, so tapping a top-level
item goes to that destination's route from any of its pages. A phone has two
exceptions. On the destination's own route its tab scrolls the page to the
top, and with onShowListChange its tab shows the list instead.
The top pane's tabBar decides what the phone tab bar does while that pane
is on top. See Pane.
Give overview to a destination whose route would only say "pick one". An
inbox is the list you choose from, so keep the list.
Accessibility
Navigator.Primary renders one <nav> per orientation. The phone bar adds
" tabs" to your label, so aria-label='Main' gives "Main" and "Main tabs".
aria-label is required on Navigator.Primary and Navigator.Secondary.
Each capsule is a list named by its GroupTitle. The title stays in the
accessibility tree while collapsed.
Tile names are visually hidden text, not aria-label, so they translate with
the page. The tooltip is aria-hidden, and a badge's label is announced after
the name.
The current page's item or row has aria-current='page'. A destination's
tile reads as current on any of its pages.
Navigator.ExpandToggle is a button with aria-expanded and
aria-controls. Its label is "Expand sidebar" or "Collapse sidebar".
Navigator.Brand is a link named by its content, such as "Oztix" from the
default Logo, or your mark's alt. Pick a name that reads in both states.
Tab order follows the regions: brand and toggle, cluster, then pinned items.
While the phone bar is collapsed, only its circles are in the tab order. The
tabs that scale away stay in the accessibility tree, so a screen reader still
has every destination.
Menus are Base UI menus. Arrow keys move between rows, typeahead jumps to
one, and Escape closes the menu and returns focus to its trigger. A menu is
named by its item, or by its own aria-label.
The pending indicator is aria-hidden and announces nothing. A pane with
pending carries aria-busy, and the arrival is the router's to announce, as
Next's own route announcer does.
Returns a destination's Navigator.Secondary items as data, for a layout
Navigator.SecondaryItems doesn't cover. Pass the destination item's value,
or omit it for the active destination. It returns null when that destination
has no secondary nav. Loose items come back as a group with no title.
Field
Type
Description
value
string
The destination item's value
label
ReactNode
The destination item's label
href
string | undefined
The destination item's href
groups
NavigatorSecondaryGroup[]
Its groups, in source order
groups[].title
ReactNode | undefined
The group's Navigator.GroupTitle
groups[].items
NavigatorSecondaryItem[]
The group's items
Each item has the fields of its Navigator.Item: value, label, href,
icon, description and badge, plus current, which is true when it is the
active value.