Sidebar
Persistent navigation panel for organizing app-level navigation.
The <Sidebar> provides persistent, app-level navigation placed on the left side of the screen. It organizes navigation items into a vertical list, supports drill-down sub-navigation for nested sections, and adapts between a desktop panel and a mobile sheet overlay. For applications with several top-level sections, a two-level navigation mode pairs a persistent icon rail with a section panel.
Anatomy
A sidebar consists of a panel alongside the main content area. The panel is organized into three zones: a sticky header, a scrollable navigation area, and a sticky footer.
- Header: Sticky top area for branding, logos, or workspace switchers.
- Group label: A non-interactive section heading to organize items into logical groups.
- Navigation: Scrollable area containing navigation items with drill-down support.
- Sidebar: The panel itself, with header, navigation, and footer zones.
- Toggle: A button placed outside the sidebar to open or close it.
- Item: A navigation entry that is either a link or a branch that opens a sub-panel.
- Separator: A visual divider between groups of items.
- Footer: Sticky bottom area for user profiles, settings, or secondary actions.
With two-level navigation, the sidebar splits into a narrow rail of top-level destinations and a panel showing the active section's navigation:
- Rail: The persistent strip of top-level destinations. It never disappears. Collapsing only narrows it to icons.
- Rail item: A stacked icon-and-label tile. With a nested
Sidebar.Navit is a section that fills the panel. With only anhrefit is a direct link. - Pinned item: A rail item declared inside
Sidebar.Footer, pinned to the bottom of the rail (e.g. a help center). - Section panel: The active section's navigation, headed by the panel title. This is the part the toggle collapses.
Appearance
The appearance of a component can be customized using the variant and size props. These props adjust the visual style and dimensions of the component, available values are based on the active theme.
| Property | Type | Description |
|---|---|---|
variant | - | The available variants of this component. |
size | - | The available sizes of this component. |
Usage
The sidebar is the central navigation of every application. It is part of the default app layout and how users move between sections. It sits on the left side of the screen and is organized into a header for branding, a scrollable navigation area, and a footer for secondary actions. On mobile, it adapts to a sheet overlay.
In most applications, the sidebar is paired with a top navigation to form an L-shaped app shell. The top navigation handles global actions like search, breadcrumbs, and user menus, while the sidebar handles section-level navigation.
Single-column navigation items are text-only. If your application needs icons
alongside every navigation label, coordinate across all applications first to
ensure consistency. The exception is two-level
navigation, whose rail tiles are icon-first by design,
so icon is required on Sidebar.RailItem.
Header and Footer
Above and below the scrollable navigation sit two sticky areas: the header and the footer. They stay visible while users scroll through nav items, but neither is meant for navigation itself.
Put your branding in the header. The app logo, the product name, or both. It tells users where they are and that is all it should do. When using a top navigation, place the application logo in the sidebar header, not in the top navigation bar.
Secondary actions go in the footer. A link to help or support, a settings shortcut, or a way to send feedback. Things users reach for occasionally, not on every visit.
Organizing navigation
Think about what your users need to get to most often and put those items at the top. People scan vertical lists from top to bottom, so the order matters.
A common mistake is to sort items alphabetically or mirror the internal system structure. Both ignore how people actually work. An "Analytics" link sorted to the top alphabetically is unhelpful if users open it once a month, while "Orders", the thing they check ten times a day, is buried further down. Order by frequency of use, not by the alphabet or your org chart.
When the list gets long, break it into groups using section headings (<Sidebar.GroupLabel>) and visual dividers (<Sidebar.Separator>). Group items by what the user is trying to do, not by how your system is structured internally. Avoid putting user-generated or unbounded content in the navigation, since lists that can grow without limit make the sidebar hard to use.
If users need to see all items at a glance, groups with headings are the right choice. If a section has its own sub-pages that users only visit occasionally, nest them behind a branch item instead. See drill-down navigation for more on nesting.
Do
Keep labels short. Lead with the most meaningful word, since users scan the beginning first. "Order history" beats "History of orders."
Don't
Don't use long, technical labels like "Transaction management module" when a simple word like "Orders" works.
Drill-down navigation
Use drill-down when a group of sub-pages belongs together under one parent, like "Settings" with sub-pages for profile, notifications, and security. Clicking a branch item opens a sub-panel with a back button to return.
Don't use drill-down just to shorten a long list. Every branch adds a click, so it should only be used when the sub-pages form a clear group.
The single-column sidebar supports one level of nesting. More levels would force users to remember where they are in a deep hierarchy, and navigating back through multiple levels gets tedious fast. If your whole application is organized into sections that each own their own sub-navigation, use two-level navigation instead of deeper nesting.
import { useState } from 'react';import { Headline, Sidebar, Text } from '@marigold/components';import { RouterProvider } from '@marigold/components';export default () => { const [currentPath, setCurrentPath] = useState('/dashboard'); return ( <RouterProvider navigate={setCurrentPath}> <Sidebar.Provider> <div className="flex h-100 [--ui-viewport-height:25rem]"> <Sidebar> <Sidebar.Header> <Text weight="bold">My App</Text> </Sidebar.Header> <Sidebar.Nav current={currentPath}> <Sidebar.Item href="/dashboard">Dashboard</Sidebar.Item> <Sidebar.Item href="/orders">Orders</Sidebar.Item> <Sidebar.Item href="/customers">Customers</Sidebar.Item> <Sidebar.Separator /> <Sidebar.Item id="settings" textValue="Settings"> Settings <Sidebar.Item href="/settings/profile">Profile</Sidebar.Item> <Sidebar.Item href="/settings/notifications"> Notifications </Sidebar.Item> <Sidebar.Item href="/settings/security">Security</Sidebar.Item> </Sidebar.Item> </Sidebar.Nav> </Sidebar> <main className="flex-1 p-4"> <Sidebar.Toggle /> <Headline level={2}> {currentPath .replace(/^\/settings\//, '') .replace('/', '') .replace(/-/g, ' ') .replace(/^\w/, c => c.toUpperCase())} </Headline> <Text>Click "Settings" to see the drill-down panel.</Text> </main> </div> </Sidebar.Provider> </RouterProvider> );};Two-level navigation
When an application has several top-level sections that each carry their own sub-navigation, like tickets, events, contacts, and settings, a single column starts to strain. Everything either nests behind drill-downs, or the list grows past what users can scan. Sidebar.Rail splits the navigation into two persistent levels instead: a narrow rail of icon-and-label tiles for the sections, and a panel beside it showing the active section's items. Both levels stay visible, so switching sections is one click and users never lose sight of where they are.
A Sidebar.RailItem wrapping a Sidebar.Nav is a section. Selecting it fills the panel with its navigation and lands on its first page. An item with only an href is a direct link that navigates immediately and shows no panel. Rail items declared inside Sidebar.Footer render pinned at the bottom of the rail.
import { useState } from 'react';import { AppShell, Description, Page, Panel, RouterProvider, Sidebar, Text, Title, TopNavigation,} from '@marigold/components';import { BarChart3, CalendarDays, LifeBuoy, Ticket, Users,} from '@marigold/icons';import { DemoViewport } from '@/ui/DemoViewport';const pages: Record<string, string> = { '/tickets/open': 'Open tickets', '/tickets/unassigned': 'Unassigned', '/tickets/archive': 'Archive', '/events/upcoming': 'Upcoming events', '/events/past': 'Past events', '/contacts/people': 'People', '/contacts/companies': 'Companies', '/reports': 'Reports', '/help': 'Help center',};export default () => { const [path, setPath] = useState('/tickets/open'); const label = pages[path] ?? 'Page'; return ( <DemoViewport> <RouterProvider navigate={setPath}> <AppShell> <Sidebar> <Sidebar.Rail current={path}> <Sidebar.RailItem icon={<Ticket />} id="tickets"> Tickets <Sidebar.Nav aria-label="Tickets"> <Sidebar.Item href="/tickets/open">Open</Sidebar.Item> <Sidebar.Item href="/tickets/unassigned"> Unassigned </Sidebar.Item> <Sidebar.Item href="/tickets/archive">Archive</Sidebar.Item> </Sidebar.Nav> </Sidebar.RailItem> <Sidebar.RailItem icon={<CalendarDays />} id="events"> Events <Sidebar.Nav aria-label="Events"> <Sidebar.Item href="/events/upcoming">Upcoming</Sidebar.Item> <Sidebar.Item href="/events/past">Past</Sidebar.Item> </Sidebar.Nav> </Sidebar.RailItem> <Sidebar.RailItem icon={<Users />} id="contacts"> Contacts <Sidebar.Nav aria-label="Contacts"> <Sidebar.Item href="/contacts/people">People</Sidebar.Item> <Sidebar.Item href="/contacts/companies"> Companies </Sidebar.Item> </Sidebar.Nav> </Sidebar.RailItem> {/* An item without a nested nav is a direct link — no panel. */} <Sidebar.RailItem icon={<BarChart3 />} href="/reports"> Reports </Sidebar.RailItem> {/* Rail items inside the footer render pinned at the bottom. */} <Sidebar.Footer> <Sidebar.RailItem icon={<LifeBuoy />} href="/help"> Help </Sidebar.RailItem> </Sidebar.Footer> </Sidebar.Rail> </Sidebar> <TopNavigation> <TopNavigation.Start> <Text weight="bold">Logo</Text> <Sidebar.Toggle variant="rail" /> </TopNavigation.Start> <TopNavigation.End> <Text size="sm">User Menu</Text> </TopNavigation.End> </TopNavigation> <Page> <Page.Header> <Title>{label}</Title> <Description> The rail stays put; the panel shows the active section. </Description> </Page.Header> <Panel> <Panel.Header> <Title>Overview</Title> </Panel.Header> <Panel.Content> <Text size="sm"> You are viewing <strong>{label}</strong>. Select another rail section to swap the panel, or collapse it with the toggle. </Text> </Panel.Content> </Panel> </Page> </AppShell> </RouterProvider> </DemoViewport> );};Collapsing behaves differently than in the single column: the toggle hides the panel while the rail narrows to an icon-only strip, so top-level navigation is always one click away. Re-selecting the active section reopens the panel. On small screens the rail renders as the same drawer as the single-column sidebar: one column where sections drill in (the drawer opens inside the active section), direct links are plain rows, and tapping a link closes the drawer.
The rail is paired with a top navigation that spans the full width of the screen, and AppShell switches to this layout automatically when it contains a rail. Place the brand and <Sidebar.Toggle variant="rail" /> in the top navigation's start slot.
Do
Use the rail for a small, stable set of sections (roughly 4–9) with recognizable icons. It is the app's permanent backbone, not a place for content that changes often.
Don't
Don't reach for the rail when your navigation fits one column. A flat list of pages needs no second level. The extra chrome only adds distance to every destination.
Active item
The sidebar marks the user's current page so they always know where they are. Pass the current pathname (typically from your router) to the current prop on Sidebar.Nav, and the matching item lights up automatically:
<Sidebar.Nav current={pathname}>
<Sidebar.Item href="/overview">Overview</Sidebar.Item>
<Sidebar.Item href="/analytics">Analytics</Sidebar.Item>
</Sidebar.Nav>Matching is segment-aware, so dynamic routes like /users/abc-123 correctly highlight the /users item. When a leaf inside a drill-down branch matches, the parent branch lights up too and its sub-panel opens.
With two-level navigation, pass current to Sidebar.Rail instead. It resolves both levels at once: the matching page lights up in the panel and its section is marked on the rail.
For custom matching logic, pass a predicate (href, key) => boolean instead of a string. This is useful when URLs contain segments that don't map directly to item hrefs, such as a locale prefix:
<Sidebar.Nav
current={(href, key) => {
if (!href) return false;
// strip locale prefix so /en/orders matches /orders
return pathname.replace(/^\/(en|de)/, '') === href;
}}
>
<Sidebar.Item href="/orders">Orders</Sidebar.Item>
</Sidebar.Nav>For escape-hatch cases (pinned items, items without an href), the per-item active prop on Sidebar.Item still works as a local override and stacks with the resolved match. The same override exists on Sidebar.RailItem: an active rail item wins over href matching and marks itself as the current destination.
Detail pages
The sidebar shows sections and categories, not individual records. When a user opens a specific order from the "Orders" page, the detail view fills the main content area. The sidebar does not change. "Orders" stays highlighted so the user can tell which section they are in.
Do not add individual records as sidebar items. A sidebar that lists every order or ticket grows without limit and becomes unusable. The sidebar points to the list. The list points to the detail.
For hierarchies deeper than two levels, pair the sidebar with breadcrumbs. The sidebar lets users move between sections, breadcrumbs show the path back (e.g. Orders > ORD-4712).
import { useState } from 'react';import { Breadcrumbs, Headline, Inline, Link, NumericFormat, RouterProvider, Sidebar, Stack, Table, Text,} from '@marigold/components';const orders = [ { id: 'ORD-4712', customer: 'Anna Schmidt', total: 129 }, { id: 'ORD-4713', customer: 'Max Weber', total: 84.5 }, { id: 'ORD-4714', customer: 'Lena Fischer', total: 212 },];const pages: Record<string, string> = { '/dashboard': 'Dashboard', '/orders': 'Orders', '/customers': 'Customers',};const OrderList = () => ( <Stack space={4}> <Headline level={2}>Orders</Headline> <Table aria-label="Orders" selectionMode="none"> <Table.Header> <Table.Column rowHeader>Order</Table.Column> <Table.Column>Customer</Table.Column> <Table.Column>Total</Table.Column> </Table.Header> <Table.Body> {orders.map(order => ( <Table.Row key={order.id}> <Table.Cell> <Link href={`/orders/${order.id}`}>{order.id}</Link> </Table.Cell> <Table.Cell>{order.customer}</Table.Cell> <Table.Cell> <NumericFormat value={order.total} style="currency" currency="EUR" /> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> </Stack>);const OrderDetail = ({ id }: { id: string }) => { const order = orders.find(o => o.id === id); if (!order) return null; return ( <Stack space={4}> <Headline level={2}>{order.id}</Headline> <Text> Customer: {order.customer} <br /> Total:{' '} <NumericFormat value={order.total} style="currency" currency="EUR" /> </Text> </Stack> );};export default () => { const [currentPath, setCurrentPath] = useState('/orders'); const orderMatch = currentPath.match(/^\/orders\/(.+)$/); const selectedOrder = orderMatch?.[1] ?? null; const activePath = selectedOrder ? '/orders' : currentPath; return ( <RouterProvider navigate={setCurrentPath}> <Sidebar.Provider> <div className="flex h-100 [--ui-viewport-height:25rem]"> <Sidebar> <Sidebar.Header> <Text weight="bold">My App</Text> </Sidebar.Header> <Sidebar.Nav current={currentPath}> <Sidebar.Item href="/dashboard">Dashboard</Sidebar.Item> <Sidebar.Item href="/orders">Orders</Sidebar.Item> <Sidebar.Item href="/customers">Customers</Sidebar.Item> </Sidebar.Nav> </Sidebar> <main className="grid flex-1 grid-rows-[auto_1fr] gap-8 overflow-auto pl-4"> <Inline alignY="center"> <Sidebar.Toggle /> <Breadcrumbs> <Breadcrumbs.Item href={activePath}> {pages[activePath] ?? activePath} </Breadcrumbs.Item> {selectedOrder && ( <Breadcrumbs.Item href={`/orders/${selectedOrder}`}> {selectedOrder} </Breadcrumbs.Item> )} </Breadcrumbs> </Inline> {activePath === '/orders' && !selectedOrder && <OrderList />} {activePath === '/orders' && selectedOrder && ( <OrderDetail id={selectedOrder} /> )} {activePath === '/dashboard' && ( <Headline level={2}>Dashboard</Headline> )} {activePath === '/customers' && ( <Headline level={2}>Customers</Headline> )} </main> </div> </Sidebar.Provider> </RouterProvider> );};Don't
Don't add individual records as sidebar items. The sidebar points to the list. The list points to the detail.
Do
For hierarchies deeper than two levels, pair the sidebar with breadcrumbs to show the path back.
Collapsing
Users can collapse or expand the sidebar with the toggle button or the keyboard shortcut Cmd+B / Ctrl+B. The sidebar remembers this preference across page loads. When combined with a top navigation, the sidebar toggle button is typically placed in the top navigation's start slot.
With two-level navigation, the same toggle and shortcut collapse the section panel while the rail narrows to an icon-only strip, so top-level navigation never disappears.
Accessibility
The sidebar renders as a <nav> element so screen readers can identify it as a navigation landmark. The keyboard shortcut Cmd+B / Ctrl+B toggles it globally. When drilling into a sub-panel, focus moves to the back button, and returns to the branch trigger when navigating back. The active item is announced as the current page. On mobile, the overlay traps focus and can be dismissed with Escape. Built-in strings support German and English localization.
Two-level navigation exposes two navigation landmarks: the rail itself ("Primary navigation") and the active section's panel, named after its section. The current page announces aria-current="page" (on the panel leaf, or on a direct-link rail item), while its section ancestor on the rail announces aria-current="true". Rail tiles are plain, always-tabbable links. Arrow keys (and Home / End) also move between them as a shortcut. Switching sections moves focus to the panel heading so the swapped content is announced. Within the section panel the tab stop follows the current page: when the route changes, Tab re-enters the panel at the active item rather than wherever focus last rested. Collapsed, icon-only tiles reveal their label as a tooltip on hover or focus.
Props
Sidebar.Provider
Prop
Type
Sidebar
Prop
Type
Sidebar.Header
Prop
Type
Sidebar.Nav
Accessibility props (1)
Sidebar.Item
Prop
Type
Sidebar.GroupLabel
Prop
Type
Sidebar.Footer
Prop
Type
Sidebar.Rail
Prop
Type
Accessibility props (1)
Prop
Type
Sidebar.RailItem
Prop
Type
Alternative components
- Tabs: Use when content groups are at the same hierarchy level and users switch between views within a single page, not across the application.
- Accordion: Use for collapsing content sections within a page, not for app-level navigation.
- Breadcrumbs: Use to show the user's location within a hierarchy. Breadcrumbs complement a sidebar but don't replace it.
- Top Navigation: Use for the horizontal top bar that houses global actions like search, breadcrumbs, and user menus. Pair it with a sidebar for the standard L-shaped app shell.