MiHCM
navigation/@mihcm/ui·v0.21.0·stable

Main Sidebar

Figma-style main app sidebar. Compact 46px desktop rail, mobile bottom menu, expandable drilldown panel with icon, title, description, badges, nested levels, and custom panel slots. IconSidebar remains as a compatibility alias.

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:

VariantBehaviourPick when
drilldown (default)Rail click opens a panel; deeper levels replace it; breadcrumb to step backClassic app shell, layout reflows (Linear, Porsche)
floatingPanel floats over content, dismiss on outside click / EscContent shouldn't shift when nav opens (Notion, macOS Settings)
columnsMiller columns — each drill keeps the previous column visibleDeep hierarchies, power users (Finder, NetNewsWire)
commandSearch-first; typing filters the whole tree; arrow keys + EnterKeyboard-first power users (Vercel, Raycast)
hoverHovering an icon pops a floating mini-menu, drillable insideDense 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

PropTypeDescription
itemsMainSidebarItem[]The navigation tree.

Variant + visual

PropTypeDefaultDescription
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.
panelWidthnumber | string288 (240 columns, 360 command, 260 hover)Width of the drill / floating / hover / command panel. Number → px; string → any CSS length.
expandedWidthnumber | string'15rem'Width of the wide expanded sidebar (when not collapsed).
collapsedWidthnumber | 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

PropTypeDefaultDescription
activeKeystringCurrently-selected leaf. Highlights the active item across rail, expanded sidebar, and panel ancestors.
onItemSelect(key, item) => voidFires on every rail or panel click.
expandedbooleanControlled — panel open/closed.
defaultExpandedbooleanfalseUncontrolled initial open state.
onExpandedChange(open) => voidNotified when panel open state changes.
collapsedbooleanControlled — narrow rail vs wide expanded sidebar.
defaultCollapsedbooleantrueUncontrolled initial collapse state. Defaults to the narrow rail for backward compatibility.
onCollapsedChange(collapsed) => voidNotified when collapse state changes.
collapsible'icon' | 'none''icon''none' disables the wide expanded shape entirely.
PropTypeDefaultDescription
searchboolean | SearchConfigfalse (true on command)Enables the search input. Pass an object to customize.
searchPlaceholderstringConvenience for search.placeholder.
onSearchChange(query) => voidNotified on every keystroke.

SearchConfig:

interface MainSidebarSearchConfig {
  placeholder?: string;
  matcher?: (item: MainSidebarItem, query: string) => boolean;
  noResultsLabel?: ReactNode;
}

Behaviour

PropTypeDefaultDescription
closeOnOutsideClickbooleantrueDismiss the panel on outside pointerdown.
showCollapseTogglebooleantrueShow the « (collapse) and » (expand) chevrons.
showLabelsWhenExpandedbooleantrueReserved — labels always render in the expanded sidebar.
mobilebooleanfalseForce the hamburger + Sheet pattern. Pair with useMediaQuery to flip on small viewports.
mobileOpenbooleanControlled 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.
defaultMobileOpenbooleanfalseInitial open state for the mobile Sheet — uncontrolled equivalent of mobileOpen.
onMobileOpenChange(open: boolean) => voidFires whenever the mobile Sheet opens or closes (works for both controlled and uncontrolled).

Variant-specific

VariantPropTypeDefaultDescription
columnscolumnsMaxVisiblenumber3Max simultaneously visible columns before horizontal scroll.
hoverhoverDelayMsnumber120Open 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.

PropTypeDefaultDescription
pinnablebooleanfalseShow a pin/unpin toggle in the drill panel header (next to Close).
pinnedKeystringControlled 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.
defaultPinnedKeystringUncontrolled initial pinned section. Ignored once pinnedKey is supplied.
onPinnedKeyChange(key: string | undefined) => voidFires when the user pins/unpins via the toggle. undefined = unpinned.
pinLabel / unpinLabelstring'Pin menu' / 'Unpin menu'A11y labels / tooltips for the toggle's unpinned / pinned state.

Slots

PropTypeRenders in
headerReactNodeTop of the rail (brand mark)
footerReactNodeBottom of the rail (settings / help)
panelHeaderReactNodeOverride the auto-rendered panel chrome (title + breadcrumb + close)
panelFooterReactNodeBelow the panel content list

A11y labels

PropDefault
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:

SlotProp
Root wrapperclassName
Rail / expanded surfacerailClassName
Drilldown / floating / columns / command / hover panelpanelClassName
Inactive rail / expanded / panel rowsitemClassName
Active rail / expanded / panel rowsactiveItemClassName

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

TokenSets
bgBoth railBg and panelBg
fgBoth railFg and panelFg

Granular

TokenDefaultApplies to
railBgvar(--color-primary)Rail + expanded sidebar background
railFgvar(--color-primary-foreground)Rail + expanded text and icons
panelBgvar(--color-card)Drill / floating / columns / command / hover panel surface
panelFgvar(--color-card-foreground)Panel title, item text, icons
accentBgvar(--color-accent)Active item background (rail + panel + expanded)
accentFgvar(--color-accent-foreground)Active item text
bordervar(--color-border)All internal dividers + outer rail border
mutedFgderived panelFg/70Descriptions, breadcrumb crumbs, badge text, icon hints
hoverBgderived panelFg/10Hover overlay on every interactive row
ringvar(--color-ring)Focus ring on every interactive row
tooltipBgvar(--color-foreground)Rail-icon hover tooltip surface
tooltipFgvar(--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, or closeOnOutsideClick (outside-click is a no-op).
  • activeKey still 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> with aria-pressed, a focus ring, and an aria-label that flips between pinLabel / unpinLabel; focus isn't stolen on toggle.

Uncontrolled usage (defaultPinnedKey, no pinnedKey) keeps the pin only for the component's lifetime.

Backward compatibility

  • IconSidebar was the original name. It remains a re-export alias of MainSidebar (same type, same props).
  • Pre-existing call sites (no variant prop) keep rendering as the default drilldown variant.

Naming

The component is documented as Main Sidebar. Import MainSidebar from @mihcm/ui/MainSidebar for new work.

  • Sheet — overlay surface used by the mobile variant.
  • Tooltip — backs the rail icon hover labels.
  • PageShell — layout wrapper that positions Main Sidebar, TopBar, and content.
  • NavigationMenu — horizontal navigation for secondary nav.