Use cases
- Main Sidebar in a multi-section product (MiHCM Security, MiHCM HR, MiHCM Payroll).
- Compact icon rail that drills into deeper navigation without leaving the page.
- Multi-level navigation up to 3+ levels deep (children → grandchildren → great-grandchildren).
- Whitelabel apps that need to retint every surface from a brand palette.
When NOT to use
- For tabs inside a card → use Tabs.
- For marketing-site navigation → use NavigationMenu.
- For ephemeral toolbars or context menus → use DropdownMenu.
Anatomy
The same MainSidebar renders any of five interaction models via the variant prop:
| Variant | Behaviour | Pick when |
|---|---|---|
drilldown (default) | Rail click opens a panel; deeper levels replace it; breadcrumb to step back | Classic app shell, layout reflows (Linear, Porsche) |
floating | Panel floats over content, dismiss on outside click / Esc | Content shouldn't shift when nav opens (Notion, macOS Settings) |
columns | Miller columns — each drill keeps the previous column visible | Deep hierarchies, power users (Finder, NetNewsWire) |
command | Search-first; typing filters the whole tree; arrow keys + Enter | Keyboard-first power users (Vercel, Raycast) |
hover | Hovering an icon pops a floating mini-menu, drillable inside | Dense workspaces where layout must never reflow |
Plus two top-level shapes (shadcn-style):
- Expanded — wide sidebar with icon + label rows and inline accordion. Universal across variants.
- Collapsed — original narrow icon rail with variant-specific click behaviour.
Toggle between the two via the « / » chevron (auto-rendered) or the collapsed prop.
Item shape
interface MainSidebarItem {
key: string; // unique id; matches activeKey
label: string; // displayed + announced by screen readers
icon?: ReactNode; // 16/20 px SVG
href?: string; // if set, renders <a>, else <button>
description?: string; // subtitle in panel header
badge?: ReactNode; // string/number gets default pill; element renders as-is
disabled?: boolean;
children?: MainSidebarItem[]; // arbitrary depth
panel?: ReactNode; // override auto-rendered child list with a custom panel
}Props — full API
Required
| Prop | Type | Description |
|---|---|---|
items | MainSidebarItem[] | The navigation tree. |
Variant + visual
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'drilldown' | 'floating' | 'columns' | 'command' | 'hover' | 'drilldown' | Interaction model. |
side | 'left' | 'right' | 'left' | Which edge the rail sits on. Floating / hover panels open on the same side. |
density | 'compact' | 'comfortable' | 'comfortable' | Rail width (48 px / 56 px) and row height. |
panelWidth | number | string | 288 (240 columns, 360 command, 260 hover) | Width of the drill / floating / hover / command panel. Number → px; string → any CSS length. |
expandedWidth | number | string | '15rem' | Width of the wide expanded sidebar (when not collapsed). |
collapsedWidth | number | string | '3.5rem' | Reserved for future per-instance rail width tuning (rail uses its variant default). |
motionPreset | 'subtle' | 'standard' | 'expressive' | 'expressive' | Animation intensity. Honours prefers-reduced-motion. |
Selection + state
| Prop | Type | Default | Description |
|---|---|---|---|
activeKey | string | — | Currently-selected leaf. Highlights the active item across rail, expanded sidebar, and panel ancestors. |
onItemSelect | (key, item) => void | — | Fires on every rail or panel click. |
expanded | boolean | — | Controlled — panel open/closed. |
defaultExpanded | boolean | false | Uncontrolled initial open state. |
onExpandedChange | (open) => void | — | Notified when panel open state changes. |
collapsed | boolean | — | Controlled — narrow rail vs wide expanded sidebar. |
defaultCollapsed | boolean | true | Uncontrolled initial collapse state. Defaults to the narrow rail for backward compatibility. |
onCollapsedChange | (collapsed) => void | — | Notified when collapse state changes. |
collapsible | 'icon' | 'none' | 'icon' | 'none' disables the wide expanded shape entirely. |
Search
| Prop | Type | Default | Description |
|---|---|---|---|
search | boolean | SearchConfig | false (true on command) | Enables the search input. Pass an object to customize. |
searchPlaceholder | string | — | Convenience for search.placeholder. |
onSearchChange | (query) => void | — | Notified on every keystroke. |
SearchConfig:
interface MainSidebarSearchConfig {
placeholder?: string;
matcher?: (item: MainSidebarItem, query: string) => boolean;
noResultsLabel?: ReactNode;
}Behaviour
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnOutsideClick | boolean | true | Dismiss the panel on outside pointerdown. |
showCollapseToggle | boolean | true | Show the « (collapse) and » (expand) chevrons. |
showLabelsWhenExpanded | boolean | true | Reserved — labels always render in the expanded sidebar. |
mobile | boolean | false | Force the hamburger + Sheet pattern. Pair with useMediaQuery to flip on small viewports. |
mobileOpen | boolean | — | Controlled open state for the mobile Sheet. When set, the built-in hamburger is suppressed so an external trigger (e.g. TopBarMenuButton) can drive open/close. |
defaultMobileOpen | boolean | false | Initial open state for the mobile Sheet — uncontrolled equivalent of mobileOpen. |
onMobileOpenChange | (open: boolean) => void | — | Fires whenever the mobile Sheet opens or closes (works for both controlled and uncontrolled). |
Variant-specific
| Variant | Prop | Type | Default | Description |
|---|---|---|---|---|
columns | columnsMaxVisible | number | 3 | Max simultaneously visible columns before horizontal scroll. |
hover | hoverDelayMs | number | 120 | Open delay after hovering an icon. |
Pinning — drilldown only
Keep a chosen section's sub-panel open across navigation, remounts, and outside-clicks until the user explicitly unpins it. All props default to today's behaviour, so existing call sites are unaffected.
| Prop | Type | Default | Description |
|---|---|---|---|
pinnable | boolean | false | Show a pin/unpin toggle in the drill panel header (next to Close). |
pinnedKey | string | — | Controlled pinned section. While set, the panel renders that section and is held open. Round-trips through onPinnedKeyChange, so it can be persisted and survive a reload. |
defaultPinnedKey | string | — | Uncontrolled initial pinned section. Ignored once pinnedKey is supplied. |
onPinnedKeyChange | (key: string | undefined) => void | — | Fires when the user pins/unpins via the toggle. undefined = unpinned. |
pinLabel / unpinLabel | string | 'Pin menu' / 'Unpin menu' | A11y labels / tooltips for the toggle's unpinned / pinned state. |
Slots
| Prop | Type | Renders in |
|---|---|---|
header | ReactNode | Top of the rail (brand mark) |
footer | ReactNode | Bottom of the rail (settings / help) |
panelHeader | ReactNode | Override the auto-rendered panel chrome (title + breadcrumb + close) |
panelFooter | ReactNode | Below the panel content list |
A11y labels
| Prop | Default |
|---|---|
collapsedLabel | 'Collapse menu' |
expandedLabel | 'Close menu' |
backLabel | 'Back' |
className overrides
Every visual slot accepts a Tailwind className that's merged through tailwind-merge, so consumer classes always win:
| Slot | Prop |
|---|---|
| Root wrapper | className |
| Rail / expanded surface | railClassName |
| Drilldown / floating / columns / command / hover panel | panelClassName |
| Inactive rail / expanded / panel rows | itemClassName |
| Active rail / expanded / panel rows | activeItemClassName |
Theming — colorScheme
Whitelabel the entire sidebar by passing any subset of these tokens. Each maps to a scoped CSS-variable override on the sidebar root, cascading through every nested affordance (rail, expanded surface, panels, breadcrumbs, badges, tooltips, dividers).
Shorthands
| Token | Sets |
|---|---|
bg | Both railBg and panelBg |
fg | Both railFg and panelFg |
Granular
| Token | Default | Applies to |
|---|---|---|
railBg | var(--color-primary) | Rail + expanded sidebar background |
railFg | var(--color-primary-foreground) | Rail + expanded text and icons |
panelBg | var(--color-card) | Drill / floating / columns / command / hover panel surface |
panelFg | var(--color-card-foreground) | Panel title, item text, icons |
accentBg | var(--color-accent) | Active item background (rail + panel + expanded) |
accentFg | var(--color-accent-foreground) | Active item text |
border | var(--color-border) | All internal dividers + outer rail border |
mutedFg | derived panelFg/70 | Descriptions, breadcrumb crumbs, badge text, icon hints |
hoverBg | derived panelFg/10 | Hover overlay on every interactive row |
ring | var(--color-ring) | Focus ring on every interactive row |
tooltipBg | var(--color-foreground) | Rail-icon hover tooltip surface |
tooltipFg | var(--color-background) | Rail-icon hover tooltip text |
Example
<MainSidebar
items={items}
colorScheme={{
railBg: '#0a2540',
railFg: '#ffffff',
panelBg: '#11304b',
panelFg: '#ffffff',
accentBg: '#ff5a1f',
accentFg: '#ffffff',
mutedFg: 'rgb(255 255 255 / 0.6)',
hoverBg: 'rgb(255 255 255 / 0.08)',
border: 'rgb(255 255 255 / 0.16)',
ring: '#ff5a1f',
tooltipBg: '#ffffff',
tooltipFg: '#0a2540',
}}
/>Omitted tokens fall through to the page's design-system defaults — so a theme that only sets accentBg retints the active state and leaves everything else on the brand.
Composition
import { MainSidebar, type MainSidebarItem } from '@mihcm/ui/MainSidebar';
import { Users, CreditCard, Shield } from '@mihcm/icons';
const items: MainSidebarItem[] = [
{
key: 'people',
label: 'People',
icon: <Users />,
children: [
{ key: 'directory', label: 'Directory' },
{ key: 'movement', label: 'Movement requests', badge: '12' },
],
},
{ key: 'payroll', label: 'Payroll', icon: <CreditCard /> },
{ key: 'security', label: 'Security', icon: <Shield /> },
];
<MainSidebar variant="drilldown" items={items} search />;
<MainSidebar variant="floating" items={items} closeOnOutsideClick />;
<MainSidebar variant="columns" items={items} columnsMaxVisible={3} />;
<MainSidebar variant="command" items={items} searchPlaceholder="Search all menus…" />;
<MainSidebar variant="hover" items={items} hoverDelayMs={80} />;Responsive behaviour — mobile
Pass mobile to render a hamburger trigger that opens the sidebar in a Sheet overlay. The page area stays clear. Pair with a media query for adaptive rendering:
import { useMediaQuery } from '@/hooks/use-media-query';
const isHandheld = useMediaQuery('(max-width: 768px)');
<MainSidebar mobile={isHandheld} items={items} colorScheme={brand} />The mobile rendering reuses the existing Sheet + ExpandedSidebar primitives — same search, breadcrumbs, theming, and a11y as desktop.
Drive the mobile Sheet from the TopBar
By default the mobile rendering ships its own hamburger trigger. To put the trigger in the app's TopBar instead, opt into controlled mode with mobileOpen + onMobileOpenChange. The built-in hamburger is suppressed automatically so you never render two:
import { useState } from 'react';
import { TopBar, TopBarLogo, TopBarMenuButton } from '@mihcm/ui/TopBar';
import { MainSidebar } from '@mihcm/ui/MainSidebar';
function AppShell() {
const [navOpen, setNavOpen] = useState(false);
return (
<>
<TopBar
start={
<>
<TopBarMenuButton onClick={() => setNavOpen(true)} />
<TopBarLogo />
</>
}
/>
<MainSidebar
mobile
items={items}
mobileOpen={navOpen}
onMobileOpenChange={setNavOpen}
/>
</>
);
}TopBarMenuButton is lg:hidden (visible on phones + tablets up to 1023px, hidden on desktop ≥1024px). When the viewport flips to desktop the mobile Sheet stays controllable but the trigger button disappears, so wrap your MainSidebar switch with the same media query if you want the desktop rail back.
Uncontrolled usage (no mobileOpen) keeps the previous behaviour — the sidebar manages open state internally and renders its own hamburger.
Pinning a section — drilldown
In the drilldown variant the panel collapses on every navigation. Pass pinnable to add a pin toggle to the panel header; a pinned section then stays open while the user moves between its pages. Make pinnedKey controlled to persist the pin (so it survives a full reload):
import { useNavStore } from '@/stores/nav'; // e.g. Zustand + localStorage
function AppSidebar() {
const pinnedKey = useNavStore((s) => s.pinnedKey);
const setPinnedKey = useNavStore((s) => s.setPinnedKey);
return (
<MainSidebar
items={items}
variant="drilldown" // pinning is drilldown-only
collapsible="icon"
activeKey={route} // active route still highlights inside the pinned panel
pinnable
pinnedKey={pinnedKey}
onPinnedKeyChange={setPinnedKey}
/>
);
}While a section is pinned:
- The panel is held open and keeps rendering the pinned section's children regardless of
activeKey, remounts, orcloseOnOutsideClick(outside-click is a no-op). activeKeystill drives the active-item highlight inside the panel — pinning changes which panel stays open, not which item reads as active.- The pinned section's rail icon won't collapse it — only the header toggle, Esc, or Close unpins. Drilling deeper inside the pinned section still works.
- The toggle is a real
<button>witharia-pressed, a focus ring, and anaria-labelthat flips betweenpinLabel/unpinLabel; focus isn't stolen on toggle.
Uncontrolled usage (defaultPinnedKey, no pinnedKey) keeps the pin only for the component's lifetime.
Backward compatibility
IconSidebarwas the original name. It remains a re-export alias ofMainSidebar(same type, same props).- Pre-existing call sites (no
variantprop) keep rendering as the defaultdrilldownvariant.
Naming
The component is documented as Main Sidebar. Import MainSidebar from @mihcm/ui/MainSidebar for new work.
Related
- Sheet — overlay surface used by the
mobilevariant. - Tooltip — backs the rail icon hover labels.
- PageShell — layout wrapper that positions Main Sidebar, TopBar, and content.
- NavigationMenu — horizontal navigation for secondary nav.