Sidebar
Two components render the navigation of the app, and they are the same design rotated. Sidebar is the vertical rail. Menubar is the horizontal bar. Both take their sections as a prop, typed by packages/azucar/packages/azucar/src/lib/navigation.ts, expand a section on hover, and slide one shared pill onto the active item.
import Sidebar from "@haloid/azucar/sidebar";import Menubar from "@haloid/azucar/menubar";
No page mounts either one. The layout of the (app) route group owns Menubar. The /design layout owns Sidebar. This is a constraint, not a convention, for the reason below.
The two of them
Both previews below are real routes in a frame, at true scale. Hover a section title to open it. Click an item and watch the pill travel while the page slides in under it. Navigation stays inside the frame, and the address bar follows it.
They are frames, not live demos, and you should know why before you use either one. The pill of the active item is a single element shared across route changes, keyed layoutId="selected-sidebar-item-bg". Only the active item renders it, so a navigation hands it from the outgoing item to the incoming one and it appears to fly. Two navigations mounted at once means two elements that claim one layoutId. That is why Sidebar and Menubar live in different route groups and can never be mounted together. An inline demo here would be that collision, aimed at the rail next to this text. A frame is a separate document and a separate React root, so the shared id stays unshared.
The sections in the frames are preview data, not the app's own, and their paths point back inside the frame. That is the sections prop doing the same job it does for this docs site. The page bodies are stand-ins, cheap enough to mount on every click.
Each frame pairs its nav with PageTransition, which is why the body slides rather than swaps. The direction is not decorative: it comes from getNavDirection reading the same section order the nav renders, so moving down the rail slides one way and moving back up slides the other. The axis follows the nav too: the bar slides pages on x, the rail slides them on y. A page moving perpendicular to the nav that caused it reads as unrelated to the click. The real /design layout omits the transition on purpose, so the rail beside this text does not do what the rail in the frame does.
Sections
Both components render the same shape, authored once in packages/azucar/src/lib/navigation.ts:
interface SidebarSection {title: string;items: { title: string; path: string }[];devOnly?: boolean;}
visibleSidebarSections is sidebarSections with the devOnly sections dropped outside development. That flag is how this docs site reaches the app menubar in dev and disappears from a production build. It is a build-time filter on NODE_ENV, not an auth gate.
Both accept a sections prop, with visibleSidebarSections as the default. That is how /design feeds the rail a tree derived from the MDX folders instead of the app routes, and how the frames above feed both a tree that points back inside the frame. PageTransition takes the same prop for the same reason: direction has to be read against the nav actually on screen.
Open a section
A section is open when it contains the current page, or when the pointer is over it. There is no click, no toggle, and no persisted state. The section that holds the active route is always open. Every other section opens on hover and closes when you leave.
Items stagger in 0.05s apart, and each one clears a 5px blur as it arrives. The chevron beside the title turns to point at what opened.
Two axes
The differences between the two files are mechanical. Each one is the same idea resolved along a different axis:
- The rail is a column and the bar is a row. The rail shows the logo with its wordmark. The bar shows the mark alone.
- Expansion animates
heightin the rail andwidthin the bar. Neither uses motion'slayout. Animating the box directly reflows the container every frame, so neighboring sections slide along with the growth instead of snapping to their new positions when it ends. - Items enter on
y: -15in the rail andx: -15in the bar. They arrive from the direction the section grew. - The chevron rotates opposite ways. The rail's chevron points right when closed and down when open. The bar's chevron points down when closed and right when open. Both point into the items.
- Item spacing differs for containment reasons. The rail wraps its items in
flow-rootso theirmt-2is contained by the block formatting context. Without it the margin collapses through, sits outside the animatedheight, and snaps the layout when the section unmounts. The bar usespl-4andw-maxinstead of a gap, so items keep their natural width while the animated width sweeps under them, and the title-to-items spacing collapses to zero along with it.
The collapsing variant sets pointerEvents: "none" in both. A section mid-collapse still overlaps its neighbor. Without this it would intercept the hover of that neighbor.
Route matching
matchNavPath decides which item is active. An item matches when the pathname equals its path or sits beneath it. The longest match wins, so a nested route lights up its parent nav item instead of nothing.
matchNavPath("/reconciliation/overview"); // "/reconciliation/overview"matchNavPath("/reconciliation/overview/tor5"); // "/reconciliation/overview"matchNavPath("/settings"); // null
The same function backs getNavDirection, which compares two paths against the flattened order of the sections it was handed and returns -1, 0, or 1. That tells the page transition which way to slide, so nav order and transition direction cannot drift apart. Reorder a section and you reorder the animation.
API
// packages/azucar/src/sidebar.tsxexport default function Sidebar(props: {sections?: SidebarSection[]; // default visibleSidebarSections}): React.JSX.Element;// packages/azucar/src/menubar.tsxexport default function Menubar(props: {sections?: SidebarSection[]; // default visibleSidebarSections}): React.JSX.Element;// packages/azucar/src/lib/navigation.tsexport const sidebarSections: SidebarSection[];export const visibleSidebarSections: SidebarSection[]; // devOnly dropped in productionexport function matchNavPath(pathname: string,sections?: SidebarSection[], // default visibleSidebarSections): string | null;export function getNavDirection(from: string,to: string,sections?: SidebarSection[], // default visibleSidebarSections): -1 | 0 | 1;
Items render as Button with variant="ghost" inside Link. Both of those pages describe this navigation pattern.
Limits
- Never mount both, and never mount two of either. The shared
layoutIdis global to the app. - A section title is both its React key and its hover identity, so two sections with the same title would open and close together.
- Sections open on hover only. The title is not focusable and the items of a closed section are unmounted, so a keyboard user can reach the items of the active section and nothing else.
- The active item carries no
aria-current. The pill is the only signal, and it is decoration to a screen reader.