SidebarATM
A composable, themeable application sidebar with collapsible state, mobile sheet overlay, collapsible groups, submenus, badges, and keyboard shortcut (Cmd+B).
On this page (9)
Β§Default
Full sidebar with collapsible groups, submenus, badges, and toggle button. Press βB to toggle.
Β§Right Side
Add data-side='right' to dock the sidebar to the right edge - the border flips to its left side automatically. The panel rounds only its exposed left corners and stays square (docked) on the right; the trigger sits on the right, next to the panel.
Β§Mobile sheet
Below 768px the rail hides; the documented data-sidebar-mobile button opens the <dialog class='sidebar-mobile'> sheet. Booted in Phone mode - click Menu, close with the X or Escape.
Β§Icons and emojis
A .sidebar-icon span puts an icon or an emoji in front of any label - links, submenus and the uppercase group headings alike. It is a fixed 1rem box, so every label lines up, and it stays visible on the collapsed rail.
Β§Activity indicators
A .sidebar-dot at the end of a row marks news or activity - on a link, a submenu or a whole group (so a collapsed section still says something inside is new). Colors via data-variant (success / warning / info / destructive), a ping via data-animate='ping', and it pairs with a .sidebar-badge. Name it: role='img' + aria-label. On the collapsed rail the dot moves onto the icon's corner.
Β§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
Β§States
Named states via the shared State API, driven per instance through the bound api:
default- expanded (the authored width;setState('default')restores it)collapsed- icon-rail width via the documenteddata-state="collapsed"attribute
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/sidebar-{state}.png.
Machine contract - verified against sidebar.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
collapsed | boolean | true, false | false | Rail mode - icon-only width carried by data-state="collapsed". |
Β§CSS view file
Full sidebar layout with collapsible state, mobile dialog overlay, collapsible groups, submenus, badges, and responsive behavior.
/* -- Sidebar component ------------------------------------------ *//* Application sidebar with collapsible state, mobile sheet overlay, *//* collapsible groups, submenus, and keyboard shortcut (Cmd+B). */@layer components { /* ββ Shell layout βββββββββββββββββββββββββββββββββββββββββββ */ .sidebar-layout { display: flex; min-height: 100vh; min-height: 100dvh; } /* ββ Sidebar panel ββββββββββββββββββββββββββββββββββββββββββ */ .app-sidebar { display: flex; flex-direction: column; width: var(--sidebar-width, 16rem); height: 100vh; height: 100dvh; position: sticky; top: 0; background-color: var(--sidebar); border-right: 1px solid var(--sidebar-border); flex-shrink: 0; transition: width 200ms ease; overflow: hidden; z-index: 30; /* Right side */ &[data-side="right"] { border-right: none; border-left: 1px solid var(--sidebar-border); order: 1; } /* Collapsed - icon-only rail */ &[data-state="collapsed"] { width: var(--sidebar-width-collapsed, 3.5rem); & .sidebar-link span:not(.sidebar-icon, .sidebar-dot), & .sidebar-section-title, & .sidebar-group-action, & .sidebar-badge, & .sidebar-footer, & .sidebar-logo-text { display: none; } & .sidebar-link { justify-content: center; padding: 0.5rem; gap: 0; } & .sidebar-header { justify-content: center; padding: 0.75rem 0.5rem; } & .sidebar-group > summary { justify-content: center; } & .sidebar-group > summary span:not(.sidebar-icon, .sidebar-dot) { display: none; } & .sidebar-group > summary > svg:last-child { display: none; } /* The nested-nav indent (0.75rem) would offset submenu icons from the rail column - drop it while collapsed. */ & .sidebar-group > .sidebar-nav { padding-left: 0; } /* Submenu summary (e.g. Settings/cog) is NOT a .sidebar-link - it needs the same centering or its icon drifts off the icon column. Expanded submenu items have no icons, so the nav stays hidden while collapsed. */ & .sidebar-submenu > summary { justify-content: center; padding: 0.5rem; gap: 0; } & .sidebar-submenu > summary span:not(.sidebar-icon, .sidebar-dot) { display: none; } & .sidebar-submenu > summary > svg:last-child { display: none; } & .sidebar-submenu > .sidebar-nav { display: none; } } /* Hidden on mobile by default - shown via dialog */ @media (max-width: 767px) { display: none; } } /* ββ Sidebar header βββββββββββββββββββββββββββββββββββββββββ */ .sidebar-header { display: flex; align-items: center; padding: 1rem; gap: 0.5rem; border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; } .sidebar-logo { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--sidebar-foreground); text-decoration: none; } /* ββ Sidebar content (scrollable area) ββββββββββββββββββββββ */ .sidebar-content { display: flex; flex-direction: column; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 0.5rem; gap: 0.5rem; scrollbar-width: thin; scrollbar-color: var(--sidebar-border) transparent; } /* ββ Sidebar nav ββββββββββββββββββββββββββββββββββββββββββββ */ .sidebar-nav { display: flex; flex-direction: column; gap: 0.125rem; } /* ββ Nav links ββββββββββββββββββββββββββββββββββββββββββββββ */ .sidebar-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; color: var(--sidebar-foreground); text-decoration: none; transition: background-color 150ms ease; position: relative; &:hover { background-color: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } &[data-active="true"], &[aria-current="page"] { background-color: var(--sidebar-accent); color: var(--sidebar-accent-foreground); font-weight: 500; } &:focus-visible { outline: 2px solid var(--sidebar-ring); outline-offset: -2px; } & svg { width: 1rem; height: 1rem; flex-shrink: 0; } } /* ββ Menu badge βββββββββββββββββββββββββββββββββββββββββββββ */ .sidebar-badge { margin-left: auto; font-size: 0.6875rem; font-weight: 500; padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); background-color: var(--sidebar-primary); color: var(--sidebar-primary-foreground); line-height: 1; } /* ββ Leading icon / emoji βββββββββββββββββββββββββββββββββββ */ /* <span class="sidebar-icon"> in front of a link, submenu or group label: holds an <svg> or an emoji in a fixed 1rem box, so labels line up and the collapsed rail keeps showing it. */ .sidebar-icon { display: inline-grid; place-items: center; width: 1rem; height: 1rem; flex-shrink: 0; font-size: 0.9375rem; font-style: normal; line-height: 1; text-transform: none; letter-spacing: 0; & svg { width: 1rem; height: 1rem; } } .sidebar-group > summary .sidebar-icon { width: 0.875rem; height: 0.875rem; font-size: 0.8125rem; } .sidebar-group > summary .sidebar-icon svg { width: 0.875rem; height: 0.875rem; } /* ββ Activity dot βββββββββββββββββββββββββββββββββββββββββββ */ /* <span class="sidebar-dot"> at the end of a link / summary marks news or activity (name it: role="img" aria-label="New"). Colors via data-variant, a ping via data-animate="ping"; on the collapsed rail it sits on the icon's corner. */ .sidebar-dot { --_c: var(--sidebar-primary); position: relative; width: 0.5rem; height: 0.5rem; flex-shrink: 0; margin-left: auto; border-radius: 999px; background-color: var(--_c); &[data-variant="destructive"] { --_c: var(--destructive); } &[data-variant="success"] { --_c: oklch(0.65 0.17 150); } &[data-variant="warning"] { --_c: oklch(0.78 0.16 75); } &[data-variant="info"] { --_c: oklch(0.62 0.17 250); } &[data-animate="ping"]::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background-color: var(--_c); animation: sidebar-dot-ping 1.4s cubic-bezier(0, 0, 0.2, 1) infinite; } } @keyframes sidebar-dot-ping { 75%, 100% { scale: 2.4; opacity: 0; } } /* dot + badge / dot + chevron share the pushed-right slot */ .sidebar-badge + .sidebar-dot, .sidebar-dot + .sidebar-badge, :is(.sidebar-group, .sidebar-submenu) > summary > .sidebar-dot ~ svg:last-child { margin-left: 0; } .app-sidebar[data-state="collapsed"] { & :is(.sidebar-group, .sidebar-submenu) > summary { position: relative; } & .sidebar-dot { position: absolute; top: 0.3125rem; right: 0.3125rem; margin: 0; width: 0.4375rem; height: 0.4375rem; } } /* ββ Section title ββββββββββββββββββββββββββββββββββββββββββ */ .sidebar-section-title { padding: 0.75rem 0.75rem 0.375rem; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-foreground); } /* ββ Collapsible group (uses <details>) ββββββββββββββββββββββ */ .sidebar-group { & > summary { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-foreground); cursor: pointer; list-style: none; border-radius: var(--radius-md); transition: background-color 150ms ease; &:hover { background-color: var(--sidebar-accent); } &::-webkit-details-marker { display: none; } & > svg:last-child { width: 0.875rem; height: 0.875rem; margin-left: auto; transition: rotate 200ms ease; } } &[open] > summary > svg:last-child { rotate: 90deg; } & > .sidebar-nav { padding: 0.25rem 0 0.25rem 0.75rem; } } /* ββ Submenu (nested <details>) ββββββββββββββββββββββββββββββ */ .sidebar-submenu { & > summary { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; color: var(--sidebar-foreground); cursor: pointer; list-style: none; transition: background-color 150ms ease; /* leading icon matches .sidebar-link's 1rem - otherwise lucide's 24px default renders the cog larger than the nav icons */ & > svg:first-child { width: 1rem; height: 1rem; flex-shrink: 0; } &:hover { background-color: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } &::-webkit-details-marker { display: none; } & > svg:last-child { width: 0.875rem; height: 0.875rem; margin-left: auto; transition: rotate 200ms ease; } } &[open] > summary > svg:last-child { rotate: 90deg; } & > .sidebar-nav { padding: 0.25rem 0 0.25rem 1.5rem; border-left: 1px solid var(--sidebar-border); margin-left: 0.75rem; } } /* ββ Group action button ββββββββββββββββββββββββββββββββββββ */ .sidebar-group-action { margin-left: auto; padding: 0.125rem; border: none; background: transparent; color: var(--muted-foreground); cursor: pointer; border-radius: var(--radius-sm); &:hover { color: var(--sidebar-foreground); background-color: var(--sidebar-accent); } & svg { width: 0.875rem; height: 0.875rem; } } /* ββ Sidebar footer βββββββββββββββββββββββββββββββββββββββββ */ .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--sidebar-border); font-size: 0.8125rem; color: var(--sidebar-foreground); flex-shrink: 0; & p { margin: 0; } } /* ββ Sidebar trigger ββββββββββββββββββββββββββββββββββββββββ */ .sidebar-trigger { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border: none; background: transparent; color: var(--foreground); cursor: pointer; border-radius: var(--radius-md); &:hover { background-color: var(--accent); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } & svg { width: 1rem; height: 1rem; } } /* ββ Mobile sidebar (<dialog> sheet overlay) ββββββββββββββββ */ .sidebar-mobile { border: none; padding: 0; margin: 0; position: fixed; inset: 0; width: var(--sidebar-width, 16rem); max-width: 80vw; height: 100%; background-color: var(--sidebar); color: var(--sidebar-foreground); z-index: 50; overflow: hidden; display: flex; flex-direction: column; opacity: 0; translate: -100% 0; transition: opacity 200ms ease, translate 200ms ease, display 200ms allow-discrete; &[open] { opacity: 1; translate: 0 0; } &[data-side="right"] { right: 0; left: auto; translate: 100% 0; &[open] { translate: 0 0; } } &::backdrop { background: oklch(0 0 0 / 0.5); opacity: 0; transition: opacity 200ms ease, display 200ms allow-discrete; } &[open]::backdrop { opacity: 1; } & .sidebar-mobile-close { position: absolute; top: 0.75rem; right: 0.75rem; width: 1.5rem; height: 1.5rem; border: none; background: transparent; color: var(--sidebar-foreground); cursor: pointer; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; &:hover { background-color: var(--sidebar-accent); } & svg { width: 0.875rem; height: 0.875rem; } } @media (min-width: 768px) { display: none; } } @starting-style { .sidebar-mobile[open] { opacity: 0; translate: -100% 0; } .sidebar-mobile[open]::backdrop { opacity: 0; } .sidebar-mobile[data-side="right"][open] { opacity: 0; translate: 100% 0; } } /* ββ Scroll lock ββββββββββββββββββββββββββββββββββββββββββββ */ /* Page behind stays put while the mobile sheet is modal (see dialog.css): `:modal` + overflow:hidden freezes the viewport at its current offset. */ html:has(.sidebar-mobile:modal) { overflow: hidden; scrollbar-gutter: stable; } /* ββ Reduced motion βββββββββββββββββββββββββββββββββββββββββ */ @media (prefers-reduced-motion: reduce) { .app-sidebar { transition: none; } .sidebar-link { transition: none; } .sidebar-group > summary > svg:last-child { transition: none; } .sidebar-submenu > summary > svg:last-child { transition: none; } .sidebar-dot[data-animate]::after { animation: none; } .sidebar-mobile { transition: none; } .sidebar-mobile::backdrop { transition: none; } } /* -- Density ---------------------------------------------------- data-density on the .app-sidebar root scales link rows and the content frame. comfortable == the unsized default (0.5rem). Independent of the collapsed rail, whose compact paddings win by higher specificity. */ .app-sidebar:where([data-density="compact"]) { & .sidebar-link { padding-block: 0.375rem; } & .sidebar-content { padding: 0.375rem; gap: 0.375rem; } } .app-sidebar:where([data-density="comfortable"]) { & .sidebar-link { padding-block: 0.5rem; } & .sidebar-content { padding: 0.5rem; gap: 0.5rem; } } .app-sidebar:where([data-density="spacious"]) { & .sidebar-link { padding-block: 0.625rem; } & .sidebar-content { padding: 0.625rem; gap: 0.625rem; } }}Β§JavaScript view file
Toggle collapse, keyboard shortcut (Cmd+B / Ctrl+B), and mobile dialog management.
// -- Sidebar --------------------------------------------------// Toggle collapse, keyboard shortcut (Cmd+B), and mobile dialog,// plus the named-state API bound per .app-sidebar (AGENTS.md "State API").// The component's own data-state attribute ("expanded"/"collapsed") is the// observable state; 'default' means the authored expanded view.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js β// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { bindGlobalKeys, defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const sidebarStates = ['default', 'collapsed'];/** * UI side of setState: 'collapsed' docks the rail to icon-width (CSS key is * the documented data-state attribute); 'default' restores the authored * state (expanded unless the markup says otherwise). */function triggerStateChange(sidebar, stateName, _config) { switch (stateName) { case 'default': sidebar.dataset.state = sidebar._defaultState ?? 'expanded'; break; case 'collapsed': sidebar.dataset.state = 'collapsed'; break; }}/** Registry-level API; pass the sidebar element explicitly. Unknown names throw. */export const sidebarApi = { setState(sidebar, stateName, config = {}) { if (!sidebarStates.includes(stateName)) { throw new Error(`sidebar: unknown state "${stateName}" (supported: ${sidebarStates.join(', ')})`); } triggerStateChange(sidebar, stateName, config); // state lives on the ELEMENT, not the module (many sidebars per page) sidebar.dataset.stateName = stateName; sidebar._stateConfig = config; }, getState(sidebar) { // reflect reality: trigger clicks and Cmd+B change data-state directly return { name: sidebar.dataset.state === 'collapsed' ? 'collapsed' : 'default', config: sidebar._stateConfig ?? {}, }; },};df$.sidebarApi = sidebarApi;df$.sidebarStates = sidebarStates;function init() {document.querySelectorAll('.app-sidebar:not([data-init])').forEach((sidebar) => { sidebar.dataset.init = ''; // snapshot the authored state + bind per sidebar: `$('#my-sidebar').api.setState('collapsed')` sidebar._defaultState = sidebar.dataset.state || 'expanded'; sidebar.api = { setState: (stateName, config) => sidebarApi.setState(sidebar, stateName, config), getState: () => sidebarApi.getState(sidebar), }; // -- Toggle button β collapse/expand ----------------------- const triggerId = sidebar.id ? `[data-sidebar-trigger="${sidebar.id}"]` : '.sidebar-trigger'; document.querySelectorAll(triggerId).forEach((trigger) => { trigger.addEventListener('click', () => { const state = sidebar.dataset.state === 'collapsed' ? 'expanded' : 'collapsed'; sidebar.dataset.state = state; // user interaction also moves the named state (keeps getState honest) sidebar.dataset.stateName = state === 'collapsed' ? 'collapsed' : 'default'; }); }); // -- Auto-collapse wiring: correct state at first paint + on row resize -- // observe the row (not the sidebar): the sidebar keeps its authored width // (flex-shrink: 0), so only the row's width reports available space. document.__sidebarAutoRo?.observe(sidebar.parentElement ?? sidebar); autoCollapseSidebar(sidebar);});// -- Mobile dialog triggers ----------------------------------document.querySelectorAll('[data-sidebar-mobile]:not([data-init])').forEach((trigger) => { trigger.dataset.init = ''; const dialog = document.getElementById(trigger.dataset.sidebarMobile); if (!dialog) return; trigger.addEventListener('click', () => { dialog.showModal(); }); // Close button inside the dialog dialog.querySelectorAll('.sidebar-mobile-close').forEach((btn) => { btn.addEventListener('click', () => { dialog.close(); }); });});}// -- Auto-collapse: too little room β dock to the rail ----------// A full-width rail only stays useful with content beside it: once the// sidebar's row (its parent) drops below AUTO_COLLAPSE_BELOW px the// component docks itself to the icon rail, restoring above// AUTO_COLLAPSE_ABOVE (hysteresis, so a scrollbar appearing never flickers// it). An explicit choice always wins: trigger clicks, Cmd+B and// api.setState all set dataset.stateName, and while that is set the auto// behavior stays out. ponytail: threshold is px-based (authored default is// 16rem); a custom --sidebar-width beyond ~24rem needs a larger constant.const AUTO_COLLAPSE_BELOW = 24 * 16; // 384pxconst AUTO_COLLAPSE_ABOVE = 28 * 16; // 448pxfunction autoCollapseSidebar(sidebar) { if (sidebar.dataset.stateName) return; // deliberate state - never fight it // available space = the sidebar's row (a .sidebar-layout or any container); // clientWidth of the parent, not the sidebar's own width (flex-shrink: 0 // keeps the authored width and overflows instead of shrinking). const avail = (sidebar.parentElement ?? document.body).clientWidth || window.innerWidth; const collapsed = sidebar.dataset.state === 'collapsed'; if (!collapsed && avail < AUTO_COLLAPSE_BELOW) sidebar.dataset.state = 'collapsed'; else if (collapsed && avail >= AUTO_COLLAPSE_ABOVE) { sidebar.dataset.state = sidebar._defaultState ?? 'expanded'; }}// One shared observer; each init() observes the sidebar's row, so container// queries / layout resizes re-run the check without a window resize.if (typeof ResizeObserver !== 'undefined' && !document.__sidebarAutoRo) { // the collapse changes layout - so it runs on the next frame, never inside // the observer's delivery (that is the "ResizeObserver loop" error) const pending = new Set(); let frame = 0; document.__sidebarAutoRo = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target.classList?.contains('app-sidebar')) pending.add(entry.target); entry.target.querySelectorAll?.('.app-sidebar').forEach((s) => pending.add(s)); } cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { pending.forEach(autoCollapseSidebar); pending.clear(); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });// -- Keyboard shortcut: Cmd+B / Ctrl+B ----------------------// Through the shared global-key listener (src/shared/keys.ts): never fires// while typing in a field or a contenteditable (where Cmd+B means bold), and// only claims the key when there is a sidebar to toggle.if (!document.__sidebarKbInit) { document.__sidebarKbInit = true; bindGlobalKeys((e) => { if (!(e.metaKey || e.ctrlKey) || e.key !== 'b') return; // Toggle the first sidebar found on the page const sidebar = document.querySelector('.app-sidebar'); if (!sidebar) return; e.preventDefault(); sidebar.dataset.state = sidebar.dataset.state === 'collapsed' ? 'expanded' : 'collapsed'; // user decision - pins against the auto-collapse heuristic sidebar.dataset.stateName = sidebar.dataset.state === 'collapsed' ? 'collapsed' : 'default'; return true; });}Comments, ideas or improvements? Edit this page's source on GitHub