App Sidebar Nav
App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a collapsible icon rail.
- Navbars
- Fintech
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. Group expand animates grid-template-rows (Chrome 107+, Safari 16+, Firefox 66+); collapsed groups use inert.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/app-sidebar-nav.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/navbars/app-sidebar-nav.tsx
1/**2 * AppSidebarNav, a fintech dashboard sidebar: workspace switcher dropdown,3 * nested groups that expand smoothly, badges, an active indicator that4 * slides between items (and onto a collapsed group's parent), and a collapse5 * button that folds the sidebar into a 72px icon rail with tooltips. Icons6 * are drawn on a 20px grid with a consistent 1.5px stroke.7 *8 * Props: workspaces, workspaceId, items (id, label, icon, badge, children),9 * footer items, user, activeId / defaultActiveId, onNavigate, collapsed /10 * defaultCollapsed, onCollapsedChange.11 * CSS vars: --fd-asn-bg, --fd-asn-ink, --fd-asn-brass.12 *13 * Keyboard: ↑ ↓ move between visible items, → opens a group (or moves into14 * it), ← closes it (or moves to its parent), Home/End jump. The workspace15 * menu supports ↑ ↓, Enter and Escape. Below 860px the sidebar becomes a16 * drawer opened from a top bar; Escape closes it and focus returns.17 * Browser support: all evergreen browsers (grid-template-rows animation for18 * groups: Chrome 107+, Safari 16+, Firefox 66+; older browsers snap).19 * Accessibility: <nav aria-label>, aria-current="page", aria-expanded on20 * groups and the switcher, menuitemradio for workspaces, tooltips mirror21 * visible labels in rail mode (the label stays in the accessible name).22 */23import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";24import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";25 26export type AsnIcon = "home" | "accounts" | "payments" | "cards" | "invoices" | "treasury" | "reports" | "settings" | "help";27export interface AsnItem { id: string; label: string; icon?: AsnIcon; badge?: string | number; badgeTone?: "alert" | "quiet"; meta?: string; children?: AsnItem[] }28export interface AsnWorkspace { id: string; name: string; meta: string; mono: string }29export interface AppSidebarNavProps {30 workspaces?: AsnWorkspace[];31 workspaceId?: string;32 onWorkspaceChange?: (id: string) => void;33 items?: AsnItem[];34 footer?: AsnItem[];35 user?: { name: string; role: string; src: string };36 activeId?: string;37 defaultActiveId?: string;38 onNavigate?: (id: string) => void;39 collapsed?: boolean;40 defaultCollapsed?: boolean;41 onCollapsedChange?: (c: boolean) => void;42 className?: string;43}44 45const WORKSPACES: AsnWorkspace[] = [46 { id: "nh", name: "Nordhavn Ceramics ApS", meta: "DKK · 3 accounts", mono: "NC" },47 { id: "ls", name: "Lindqvist Studio AB", meta: "SEK · 2 accounts", mono: "LS" },48 { id: "hb", name: "Halden Bikes AS", meta: "NOK · 5 accounts", mono: "HB" },49];50 51const ITEMS: AsnItem[] = [52 { id: "overview", label: "Overview", icon: "home" },53 { id: "accounts", label: "Accounts", icon: "accounts", children: [54 { id: "acc-op", label: "Operating", meta: "1.82M" },55 { id: "acc-pay", label: "Payroll", meta: "412K" },56 { id: "acc-eur", label: "EUR reserve", meta: "€33K" },57 ] },58 { id: "payments", label: "Payments", icon: "payments", badge: 3, badgeTone: "alert", children: [59 { id: "pay-out", label: "Outgoing" },60 { id: "pay-appr", label: "Approvals", badge: 3, badgeTone: "alert" },61 { id: "pay-sched", label: "Scheduled", badge: 8, badgeTone: "quiet" },62 ] },63 { id: "cards", label: "Cards", icon: "cards", badge: "New", badgeTone: "quiet" },64 { id: "invoices", label: "Invoices", icon: "invoices", badge: 12, badgeTone: "quiet" },65 { id: "treasury", label: "Treasury", icon: "treasury", children: [66 { id: "tr-fx", label: "FX conversions" },67 { id: "tr-dep", label: "Deposits, 3.1%" },68 ] },69 { id: "reports", label: "Reports", icon: "reports" },70];71 72const FOOTER: AsnItem[] = [73 { id: "settings", label: "Settings", icon: "settings" },74 { id: "help", label: "Help & support", icon: "help" },75];76 77function Icon({ name }: { name: AsnIcon }) {78 const p = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };79 const d: Record<AsnIcon, ReactNode> = {80 home: <><path d="M3.5 8.5 10 3.5l6.5 5V16a.5.5 0 0 1-.5.5h-3.5v-5h-5v5H4a.5.5 0 0 1-.5-.5z" {...p} /></>,81 accounts: <><path d="M3 7.5 10 3.5l7 4M4 16.5h12M5.5 8.5v6M8.5 8.5v6M11.5 8.5v6M14.5 8.5v6" {...p} /></>,82 payments: <><path d="M3.5 7h12M12.5 4l3 3-3 3M16.5 13h-12M7.5 10l-3 3 3 3" {...p} /></>,83 cards: <><rect x="2.5" y="4.5" width="15" height="11" rx="2" {...p} /><path d="M2.5 8.5h15M5.5 12.5h3" {...p} /></>,84 invoices: <><path d="M5 2.5h7l3.5 3.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" {...p} /><path d="M12 2.5V6h3.5M7.5 10h5M7.5 13h3.5" {...p} /></>,85 treasury: <><circle cx="10" cy="10" r="7" {...p} /><path d="M3 10h14M10 3c-2 2-2.8 4.4-2.8 7s.8 5 2.8 7c2-2 2.8-4.4 2.8-7S12 5 10 3z" {...p} /></>,86 reports: <><path d="M3.5 16.5h13M6 13.5v-3M10 13.5V6M14 13.5V9" {...p} /></>,87 settings: <><path d="M3.5 6h6M13.5 6h3M3.5 14h3M10.5 14h6" {...p} /><circle cx="11.5" cy="6" r="2" {...p} /><circle cx="8.5" cy="14" r="2" {...p} /></>,88 help: <><circle cx="10" cy="10" r="7" {...p} /><path d="M8 8.2a2 2 0 1 1 2.8 1.8c-.5.3-.8.7-.8 1.3v.4" {...p} /><circle cx="10" cy="13.9" r=".2" {...p} /></>,89 };90 return <svg className="fd-asn-ico" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">{d[name]}</svg>;91}92 93const Chevron = ({ cls = "" }: { cls?: string }) => (94 <svg className={cls} width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>95);96 97export function AppSidebarNav({98 workspaces = WORKSPACES,99 workspaceId,100 onWorkspaceChange,101 items = ITEMS,102 footer = FOOTER,103 user = { name: "Søren Holm", role: "Finance lead", src: "/img/people/p7.webp" },104 activeId,105 defaultActiveId = "pay-appr",106 onNavigate,107 collapsed: collapsedProp,108 defaultCollapsed = false,109 onCollapsedChange,110 className,111}: AppSidebarNavProps) {112 const uid = useId();113 const [activeState, setActive] = useState(defaultActiveId);114 const active = activeId ?? activeState;115 const [railState, setRail] = useState(defaultCollapsed);116 const rail = collapsedProp ?? railState;117 const [ws, setWs] = useState(workspaceId ?? workspaces[0].id);118 const [wsOpen, setWsOpen] = useState(false);119 const [drawer, setDrawer] = useState(false);120 const [open, setOpen] = useState<Record<string, boolean>>(() => {121 const o: Record<string, boolean> = {};122 items.forEach((i) => i.children?.some((c) => c.id === defaultActiveId) && (o[i.id] = true));123 return o;124 });125 const [tip, setTip] = useState<{ label: string; y: number; badge?: string | number } | null>(null);126 const navRef = useRef<HTMLDivElement>(null);127 const indRef = useRef<HTMLSpanElement>(null);128 const wsBtn = useRef<HTMLButtonElement>(null);129 const wsMenu = useRef<HTMLDivElement>(null);130 const asideRef = useRef<HTMLElement>(null);131 const burger = useRef<HTMLButtonElement>(null);132 133 const workspace = workspaces.find((w) => w.id === ws) ?? workspaces[0];134 const parentOf = (id: string) => items.find((i) => i.children?.some((c) => c.id === id));135 136 // Active indicator: sits on the active row, or on its parent when collapsed.137 const place = useCallback(() => {138 const nav = navRef.current, ind = indRef.current;139 if (!nav || !ind) return;140 const par = parentOf(active);141 const targetId = par && (!open[par.id] || rail) ? par.id : active;142 const el = nav.querySelector<HTMLElement>(`[data-id="${targetId}"]`);143 if (!el) {144 ind.style.opacity = "0";145 return;146 }147 const n = nav.getBoundingClientRect(), r = el.getBoundingClientRect();148 ind.style.opacity = "1";149 ind.style.transform = `translateY(${r.top - n.top + nav.scrollTop}px)`;150 ind.style.height = `${r.height}px`;151 if (par && targetId === active) ind.dataset.child = "";152 else delete ind.dataset.child;153 }, [active, open, rail]); // eslint-disable-line react-hooks/exhaustive-deps154 155 useLayoutEffect(() => {156 place();157 // Follow groups while they animate open/closed.158 let raf = 0;159 const t0 = performance.now();160 const loop = () => {161 place();162 if (performance.now() - t0 < 460) raf = requestAnimationFrame(loop);163 };164 raf = requestAnimationFrame(loop);165 const ro = new ResizeObserver(place);166 if (navRef.current) ro.observe(navRef.current);167 return () => {168 cancelAnimationFrame(raf);169 ro.disconnect();170 };171 }, [place]);172 173 const go = (id: string) => {174 setActive(id);175 onNavigate?.(id);176 setDrawer(false);177 };178 const toggleRail = () => {179 const n = !rail;180 setRail(n);181 onCollapsedChange?.(n);182 setTip(null);183 setWsOpen(false);184 };185 186 // "[" toggles the rail, like most desktop apps.187 useEffect(() => {188 const on = (e: KeyboardEvent) => {189 const t = e.target as HTMLElement;190 if (e.key !== "[" || e.metaKey || e.ctrlKey || e.altKey) return;191 if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;192 if (!matchMedia("(min-width: 861px)").matches) return;193 setRail((r) => {194 onCollapsedChange?.(!r);195 return !r;196 });197 setTip(null);198 };199 window.addEventListener("keydown", on);200 return () => window.removeEventListener("keydown", on);201 }, [onCollapsedChange]);202 203 // Workspace menu.204 useEffect(() => {205 if (!wsOpen) return;206 const m = wsMenu.current;207 const sel = m?.querySelector<HTMLElement>('[aria-checked="true"]') ?? m?.querySelector<HTMLElement>("[role^=menuitem]");208 sel?.focus();209 const out = (e: PointerEvent) => {210 if (!m?.contains(e.target as Node) && !wsBtn.current?.contains(e.target as Node)) setWsOpen(false);211 };212 document.addEventListener("pointerdown", out);213 return () => document.removeEventListener("pointerdown", out);214 }, [wsOpen]);215 216 const onWsKey = (e: ReactKeyboardEvent) => {217 const list = Array.from(wsMenu.current?.querySelectorAll<HTMLElement>("[role^=menuitem]") ?? []);218 const i = list.indexOf(document.activeElement as HTMLElement);219 if (e.key === "ArrowDown") { e.preventDefault(); list[(i + 1) % list.length]?.focus(); }220 else if (e.key === "ArrowUp") { e.preventDefault(); list[(i - 1 + list.length) % list.length]?.focus(); }221 else if (e.key === "Home") { e.preventDefault(); list[0]?.focus(); }222 else if (e.key === "End") { e.preventDefault(); list[list.length - 1]?.focus(); }223 else if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); setWsOpen(false); wsBtn.current?.focus(); }224 };225 const pickWs = (id: string) => {226 setWs(id);227 onWorkspaceChange?.(id);228 setWsOpen(false);229 wsBtn.current?.focus();230 };231 232 // Tree-style arrow navigation.233 const onNavKey = (e: ReactKeyboardEvent<HTMLElement>) => {234 const root = asideRef.current;235 if (!root) return;236 const list = Array.from(root.querySelectorAll<HTMLElement>("[data-nav]")).filter((el) => el.offsetParent !== null && !el.closest("[inert]"));237 const cur = document.activeElement as HTMLElement;238 const i = list.indexOf(cur);239 if (i < 0) return;240 const id = cur.dataset.id ?? "";241 const group = items.find((x) => x.id === id && x.children);242 if (e.key === "ArrowDown") { e.preventDefault(); list[Math.min(list.length - 1, i + 1)].focus(); }243 else if (e.key === "ArrowUp") { e.preventDefault(); list[Math.max(0, i - 1)].focus(); }244 else if (e.key === "Home") { e.preventDefault(); list[0].focus(); }245 else if (e.key === "End") { e.preventDefault(); list[list.length - 1].focus(); }246 else if (e.key === "ArrowRight" && group && !rail) {247 e.preventDefault();248 if (!open[id]) setOpen((o) => ({ ...o, [id]: true }));249 else list[i + 1]?.focus();250 } else if (e.key === "ArrowLeft" && !rail) {251 e.preventDefault();252 if (group && open[id]) setOpen((o) => ({ ...o, [id]: false }));253 else {254 const par = parentOf(id);255 if (par) root.querySelector<HTMLElement>(`[data-id="${par.id}"]`)?.focus();256 }257 }258 };259 260 // Mobile drawer.261 useEffect(() => {262 if (!drawer) return;263 const a = asideRef.current;264 window.setTimeout(() => a?.querySelector<HTMLElement>("[data-nav]")?.focus(), 40);265 const on = (e: KeyboardEvent) => {266 if (e.key === "Escape" && !wsOpen) {267 setDrawer(false);268 burger.current?.focus();269 }270 if (e.key === "Tab" && a) {271 const f = Array.from(a.querySelectorAll<HTMLElement>("button,a")).filter((el) => el.offsetParent !== null && !el.closest("[inert]"));272 const i = f.indexOf(document.activeElement as HTMLElement);273 if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }274 else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }275 }276 };277 const mq = matchMedia("(min-width: 861px)");278 const onMq = () => mq.matches && setDrawer(false);279 document.addEventListener("keydown", on);280 mq.addEventListener("change", onMq);281 return () => {282 document.removeEventListener("keydown", on);283 mq.removeEventListener("change", onMq);284 };285 }, [drawer, wsOpen]);286 287 const showTip = (el: HTMLElement, it: AsnItem) => {288 if (!rail || !asideRef.current) return;289 const a = asideRef.current.getBoundingClientRect(), r = el.getBoundingClientRect();290 setTip({ label: it.label, y: r.top - a.top + r.height / 2, badge: it.badge });291 };292 293 const row = (it: AsnItem, depth: number) => {294 const isGroup = !!it.children?.length;295 const isOpen = !!open[it.id] && !rail;296 const childActive = it.children?.some((c) => c.id === active);297 const current = it.id === active;298 return (299 <li key={it.id} className="fd-asn-li">300 <button301 type="button"302 data-nav=""303 data-id={it.id}304 className={`fd-asn-row${depth ? " fd-asn-sub" : ""}`}305 aria-current={current ? "page" : undefined}306 aria-expanded={isGroup && !rail ? isOpen : undefined}307 aria-controls={isGroup ? `${uid}-${it.id}` : undefined}308 data-on={current || (childActive && (!isOpen || rail)) ? "" : undefined}309 data-within={childActive ? "" : undefined}310 onClick={() => {311 if (isGroup) {312 if (rail) go(it.children!.find((c) => c.id === active)?.id ?? it.children![0].id);313 else setOpen((o) => ({ ...o, [it.id]: !o[it.id] }));314 } else go(it.id);315 }}316 onPointerEnter={(e) => showTip(e.currentTarget, it)}317 onPointerLeave={() => setTip(null)}318 onFocus={(e) => showTip(e.currentTarget, it)}319 onBlur={() => setTip(null)}320 >321 {depth === 0 && it.icon && <Icon name={it.icon} />}322 {depth > 0 && <span className="fd-asn-tick" aria-hidden="true" />}323 <span className="fd-asn-label">{it.label}</span>324 {it.meta && <span className="fd-asn-meta">{it.meta}</span>}325 {it.badge !== undefined && !(isGroup && isOpen) && (326 <span className="fd-asn-badge" data-tone={it.badgeTone ?? "quiet"}>327 {it.badge}328 {typeof it.badge === "number" && <span className="fd-asn-sr"> {it.badgeTone === "alert" ? "need attention" : "items"}</span>}329 </span>330 )}331 {isGroup && <Chevron cls="fd-asn-chev" />}332 </button>333 {isGroup && (334 <div id={`${uid}-${it.id}`} className="fd-asn-group" data-open={isOpen ? "" : undefined} inert={!isOpen ? true : undefined}>335 <ul className="fd-asn-ul">{it.children!.map((c) => row(c, depth + 1))}</ul>336 </div>337 )}338 </li>339 );340 };341 342 return (343 <div className={`fd-asn${className ? " " + className : ""}`} data-rail={rail ? "" : undefined} data-drawer={drawer ? "" : undefined}>344 <style>{css}</style>345 <div className="fd-asn-top">346 <button ref={burger} type="button" className="fd-asn-burger" aria-label="Open navigation" aria-expanded={drawer} aria-controls={`${uid}-side`} onClick={() => setDrawer(true)}>347 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6h13M3.5 10h13M3.5 14h8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>348 </button>349 <span className="fd-asn-mono" aria-hidden="true">{workspace.mono}</span>350 <span className="fd-asn-topname">{workspace.name}</span>351 <img className="fd-asn-av" src={user.src} alt={user.name} width={32} height={32} />352 </div>353 <div className="fd-asn-scrim" aria-hidden="true" onClick={() => setDrawer(false)} />354 355 <aside ref={asideRef} id={`${uid}-side`} className="fd-asn-side" aria-label="Sidebar" onKeyDown={onNavKey}>356 <div className="fd-asn-head">357 <button358 ref={wsBtn}359 type="button"360 className="fd-asn-ws"361 aria-haspopup="menu"362 aria-expanded={wsOpen}363 aria-controls={`${uid}-ws`}364 aria-label={`Workspace: ${workspace.name}. Switch workspace`}365 onClick={() => setWsOpen((o) => !o)}366 onKeyDown={(e) => {367 if (e.key === "ArrowDown" && !wsOpen) { e.preventDefault(); e.stopPropagation(); setWsOpen(true); }368 }}369 >370 <span className="fd-asn-mono" aria-hidden="true">{workspace.mono}</span>371 <span className="fd-asn-wst">372 <b>{workspace.name}</b>373 <small>{workspace.meta}</small>374 </span>375 <svg className="fd-asn-updown" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 6l3-3 3 3M5 10l3 3 3-3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>376 </button>377 {wsOpen && (378 <div ref={wsMenu} id={`${uid}-ws`} className="fd-asn-wsmenu" role="menu" aria-label="Workspaces" onKeyDown={onWsKey}>379 <p className="fd-asn-wsh">Workspaces</p>380 {workspaces.map((w) => (381 <button key={w.id} type="button" role="menuitemradio" aria-checked={w.id === ws} tabIndex={-1} className="fd-asn-wsitem" onClick={() => pickWs(w.id)}>382 <span className="fd-asn-mono" aria-hidden="true">{w.mono}</span>383 <span className="fd-asn-wst"><b>{w.name}</b><small>{w.meta}</small></span>384 {w.id === ws && <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>}385 </button>386 ))}387 <span className="fd-asn-wssep" role="separator" />388 <button type="button" role="menuitem" tabIndex={-1} className="fd-asn-wsitem fd-asn-wsadd" onClick={() => setWsOpen(false)}>389 <span className="fd-asn-plus" aria-hidden="true">+</span>390 <span className="fd-asn-wst"><b>Add a company</b><small>Takes about 6 minutes with MitID</small></span>391 </button>392 </div>393 )}394 </div>395 396 <nav className="fd-asn-nav" aria-label="Main">397 <div ref={navRef} className="fd-asn-scroll">398 <span ref={indRef} className="fd-asn-ind" aria-hidden="true" />399 <ul className="fd-asn-ul">{items.map((it) => row(it, 0))}</ul>400 </div>401 </nav>402 403 <div className="fd-asn-foot">404 <ul className="fd-asn-ul">{footer.map((it) => row(it, 0))}</ul>405 <div className="fd-asn-user">406 <img src={user.src} alt="" width={32} height={32} />407 <span className="fd-asn-wst"><b>{user.name}</b><small>{user.role}</small></span>408 </div>409 <button type="button" className="fd-asn-collapse" data-nav="" data-id="__collapse" aria-pressed={rail} aria-label={rail ? "Expand sidebar" : "Collapse sidebar"} onClick={toggleRail}410 onPointerEnter={(e) => showTip(e.currentTarget, { id: "c", label: "Expand sidebar" })} onPointerLeave={() => setTip(null)}>411 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M7.5 3.5v13" stroke="currentColor" strokeWidth="1.5" /><path className="fd-asn-carr" d="M13 8l-2 2 2 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>412 <span className="fd-asn-label">Collapse</span>413 <kbd>[</kbd>414 </button>415 </div>416 {tip && rail && (417 <span className="fd-asn-tip" role="tooltip" style={{ top: tip.y }}>418 {tip.label}419 {tip.badge !== undefined && <i>{tip.badge}</i>}420 </span>421 )}422 </aside>423 </div>424 );425}426 427const sans = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif`;428const mono = `ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace`;429 430const css = `431.fd-asn {432 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);433 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);434 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);435 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;436 --fd-asn-bg: #10241c;437 --fd-asn-bg2: #163026;438 --fd-asn-line: rgba(237,231,214,0.1);439 --fd-asn-ink: #ede7d6;440 --fd-asn-dim: #9aab9f;441 --fd-asn-brass: #c9a35c;442 --fd-asn-w: 264px;443 position: sticky; top: 0; z-index: 40; display: block; height: 100vh; height: 100dvh; margin: 0; padding: 0;444 font: 400 14px/1.4 ${sans};445 color: var(--fd-asn-ink); background: transparent;446}447.fd-asn[data-rail] { --fd-asn-w: 72px; }448.fd-asn, .fd-asn *, .fd-asn *::before, .fd-asn *::after { box-sizing: border-box; }449.fd-asn button:focus-visible { outline: 2px solid color-mix(in srgb, var(--fd-asn-brass) 80%, transparent); outline-offset: -2px; }450.fd-asn-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }451.fd-asn-ul { margin: 0; padding: 0; list-style: none; }452.fd-asn-li { margin: 0; padding: 0; }453 454.fd-asn-side { position: relative; display: flex; flex-direction: column; width: var(--fd-asn-w); height: 100%; margin: 0; padding: 12px 12px 12px; background: var(--fd-asn-bg); border-right: 1px solid #0b1a14; transition: width var(--fd-dur-3) var(--fd-ease-in-out); }455.fd-asn-head { position: relative; margin: 0 0 14px; padding: 0; }456.fd-asn-ws { display: flex; align-items: center; gap: 10px; width: 100%; margin: 0; padding: 6px; border: 1px solid transparent; border-radius: 12px; background: none; color: var(--fd-asn-ink); text-align: left; cursor: pointer; appearance: none; -webkit-appearance: none; transition: background var(--fd-dur-1) linear, border-color var(--fd-dur-1) linear; }457.fd-asn-ws:hover, .fd-asn-ws[aria-expanded="true"] { background: var(--fd-asn-bg2); border-color: var(--fd-asn-line); }458.fd-asn-mono { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 9px; font: 700 0.75rem/1 ${sans}; letter-spacing: 0.02em; color: #10241c; background: var(--fd-asn-brass); box-shadow: inset 0 -2px 0 rgba(0,0,0,0.15); }459.fd-asn-wst { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; overflow: hidden; }460.fd-asn-wst b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 0.875rem/1.15 ${sans}; color: var(--fd-asn-ink); }461.fd-asn-wst small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 0.75rem/1.1 ${mono}; color: var(--fd-asn-dim); }462.fd-asn-updown { display: block; flex: none; color: var(--fd-asn-dim); }463.fd-asn-wsmenu { position: absolute; left: 0; top: calc(100% + 6px); z-index: 5; width: 288px; margin: 0; padding: 6px; border: 1px solid rgba(237,231,214,0.14); border-radius: 14px; background: #1a362b; box-shadow: 0 24px 50px rgba(3,10,7,0.5); animation: fd-asn-pop var(--fd-dur-2) var(--fd-ease-out); transform-origin: top left; }464@keyframes fd-asn-pop { from { opacity: 0; transform: scale(0.97) translateY(-4px); } }465.fd-asn-wsh { margin: 0; padding: 8px 8px 6px; font: 500 0.6875rem/1 ${mono}; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fd-asn-dim); }466.fd-asn-wsitem { display: flex; align-items: center; gap: 10px; width: 100%; margin: 0; padding: 7px 8px; border: 0; border-radius: 9px; background: none; color: var(--fd-asn-ink); text-align: left; cursor: pointer; appearance: none; -webkit-appearance: none; }467.fd-asn-wsitem:hover, .fd-asn-wsitem:focus-visible { background: rgba(237,231,214,0.07); outline: none; }468.fd-asn-wsitem svg { display: block; flex: none; color: var(--fd-asn-brass); }469.fd-asn-wsitem .fd-asn-mono { width: 30px; height: 30px; border-radius: 8px; }470.fd-asn-wsitem:not([aria-checked="true"]) .fd-asn-mono { background: #2b4a3d; color: var(--fd-asn-ink); box-shadow: none; }471.fd-asn-wssep { display: block; height: 1px; margin: 6px 4px; background: var(--fd-asn-line); }472.fd-asn-plus { display: grid; place-items: center; flex: none; width: 30px; height: 30px; border: 1px dashed rgba(237,231,214,0.3); border-radius: 8px; font: 400 1rem/1 ${sans}; color: var(--fd-asn-dim); }473 474.fd-asn-nav { position: relative; flex: 1; min-height: 0; margin: 0; padding: 0; }475.fd-asn-scroll { position: relative; height: 100%; overflow-y: auto; overflow-x: hidden; margin: 0 -4px; padding: 0 4px; scrollbar-width: thin; scrollbar-color: #2b4a3d transparent; }476.fd-asn-ind { position: absolute; left: 4px; right: 4px; top: 0; height: 36px; border-radius: 10px; background: var(--fd-asn-bg2); box-shadow: inset 0 0 0 1px rgba(237,231,214,0.08); opacity: 0; pointer-events: none; transition: transform var(--fd-dur-3) var(--fd-spring), height var(--fd-dur-2) var(--fd-ease-out), opacity var(--fd-dur-1) linear; }477.fd-asn-ind::before { content: ""; position: absolute; left: -4px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--fd-asn-brass); }478.fd-asn-ind[data-child] { background: transparent; box-shadow: none; }479.fd-asn-ind[data-child]::before { left: 26px; top: 10px; bottom: 10px; width: 2px; border-radius: 2px; }480 481.fd-asn-row { position: relative; z-index: 1; display: flex; align-items: center; gap: 12px; width: 100%; height: 36px; margin: 0 0 2px; padding: 0 10px; border: 0; border-radius: 10px; background: none; font: 500 0.875rem/1 ${sans}; color: #c3cdc4; text-align: left; white-space: nowrap; cursor: pointer; appearance: none; -webkit-appearance: none; transition: color var(--fd-dur-1) linear, background var(--fd-dur-1) linear; }482.fd-asn-row:hover { color: var(--fd-asn-ink); background: rgba(237,231,214,0.04); }483.fd-asn-row[data-on] { color: var(--fd-asn-ink); }484.fd-asn-row[data-on] .fd-asn-ico { color: var(--fd-asn-brass); }485.fd-asn-ico { display: block; flex: none; color: var(--fd-asn-dim); transition: color var(--fd-dur-2) linear; }486.fd-asn-row:hover .fd-asn-ico { color: var(--fd-asn-ink); }487.fd-asn-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; transition: opacity var(--fd-dur-2) linear; }488.fd-asn-sub { height: 32px; padding-left: 42px; font-weight: 400; font-size: 0.8125rem; }489.fd-asn-tick { position: absolute; left: 20px; top: -2px; bottom: -2px; width: 1px; background: rgba(237,231,214,0.12); }490.fd-asn-sub[aria-current="page"] { color: var(--fd-asn-ink); font-weight: 500; }491.fd-asn-meta { font: 400 0.75rem/1 ${mono}; color: var(--fd-asn-dim); }492.fd-asn-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; margin: 0; padding: 0 6px; border-radius: 9999px; font: 600 0.6875rem/1 ${mono}; color: var(--fd-asn-ink); background: rgba(237,231,214,0.1); }493.fd-asn-badge[data-tone="alert"] { color: #10241c; background: var(--fd-asn-brass); }494.fd-asn-chev { display: block; flex: none; color: var(--fd-asn-dim); transition: transform var(--fd-dur-3) var(--fd-spring); }495.fd-asn-row[aria-expanded="true"] .fd-asn-chev { transform: rotate(90deg); }496.fd-asn-group { display: grid; grid-template-rows: 0fr; margin: 0; padding: 0; transition: grid-template-rows var(--fd-dur-3) var(--fd-ease-in-out), opacity var(--fd-dur-2) linear; opacity: 0; }497.fd-asn-group[data-open] { grid-template-rows: 1fr; opacity: 1; }498.fd-asn-group > .fd-asn-ul { min-height: 0; overflow: hidden; }499 500.fd-asn-foot { margin: 8px 0 0; padding: 10px 0 0; border-top: 1px solid var(--fd-asn-line); }501.fd-asn-user { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; padding: 6px 8px; border-radius: 10px; }502.fd-asn-user img { display: block; flex: none; width: 32px; height: 32px; border-radius: 9999px; object-fit: cover; background: #2b4a3d; }503.fd-asn-collapse { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; height: 36px; margin: 0; padding: 0 10px; border: 0; border-radius: 10px; background: none; font: 500 0.8125rem/1 ${sans}; color: var(--fd-asn-dim); cursor: pointer; appearance: none; -webkit-appearance: none; white-space: nowrap; }504.fd-asn-collapse:hover { color: var(--fd-asn-ink); background: rgba(237,231,214,0.04); }505.fd-asn-collapse svg { display: block; flex: none; }506.fd-asn-carr { transition: transform var(--fd-dur-3) var(--fd-spring); transform-origin: 12px 10px; }507.fd-asn[data-rail] .fd-asn-carr { transform: scaleX(-1); }508.fd-asn-collapse kbd { margin: 0; padding: 2px 6px; border: 1px solid var(--fd-asn-line); border-radius: 5px; background: none; font: 500 0.6875rem/1.2 ${mono}; color: var(--fd-asn-dim); }509 510.fd-asn[data-rail] .fd-asn-label, .fd-asn[data-rail] .fd-asn-meta, .fd-asn[data-rail] .fd-asn-chev, .fd-asn[data-rail] .fd-asn-wst, .fd-asn[data-rail] .fd-asn-updown, .fd-asn[data-rail] .fd-asn-collapse kbd { opacity: 0; width: 0; flex: 0 0 0; overflow: hidden; pointer-events: none; }511.fd-asn[data-rail] .fd-asn-row { gap: 0; padding: 0 14px; }512.fd-asn[data-rail] .fd-asn-badge { position: absolute; right: 6px; top: 3px; min-width: 8px; height: 8px; padding: 0; font-size: 0; }513.fd-asn[data-rail] .fd-asn-badge[data-tone="quiet"] { display: none; }514.fd-asn[data-rail] .fd-asn-ws { gap: 0; padding: 6px 4px; }515.fd-asn[data-rail] .fd-asn-user { padding: 6px 8px; gap: 0; }516.fd-asn[data-rail] .fd-asn-collapse { gap: 0; padding: 0 14px; }517.fd-asn[data-rail] .fd-asn-wsmenu { left: 0; }518.fd-asn-tip { position: absolute; left: calc(100% + 8px); z-index: 6; display: inline-flex; align-items: center; gap: 8px; margin: 0; padding: 7px 10px; border-radius: 8px; background: #ede7d6; color: #10241c; font: 600 0.8125rem/1 ${sans}; white-space: nowrap; transform: translateY(-50%); pointer-events: none; box-shadow: 0 8px 20px rgba(3,10,7,0.25); animation: fd-asn-tip var(--fd-dur-2) var(--fd-ease-out); }519.fd-asn-tip::before { content: ""; position: absolute; left: -4px; top: 50%; width: 8px; height: 8px; margin-top: -4px; background: inherit; transform: rotate(45deg); }520.fd-asn-tip i { font: 600 0.6875rem/1 ${mono}; font-style: normal; padding: 3px 6px; border-radius: 9999px; background: #10241c; color: #ede7d6; }521@keyframes fd-asn-tip { from { opacity: 0; transform: translate(-4px, -50%); } }522 523.fd-asn-top, .fd-asn-scrim { display: none; }524.fd-asn-burger { display: grid; place-items: center; width: 40px; height: 40px; margin: 0 0 0 -8px; padding: 0; border: 0; border-radius: 10px; background: none; color: var(--fd-asn-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }525.fd-asn-burger svg { display: block; }526.fd-asn-topname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 0.9375rem/1 ${sans}; color: var(--fd-asn-ink); }527.fd-asn-av { display: block; width: 32px; height: 32px; border-radius: 9999px; object-fit: cover; }528 529@media (max-width: 860px) {530 .fd-asn { --fd-asn-w: min(300px, 86vw); height: auto; position: sticky; top: 0; z-index: 40; }531 .fd-asn-top { display: flex; align-items: center; gap: 10px; height: 56px; margin: 0; padding: 0 16px; background: var(--fd-asn-bg); }532 .fd-asn-top .fd-asn-mono { width: 28px; height: 28px; border-radius: 7px; font-size: 0.6875rem; }533 .fd-asn-side { position: fixed; left: 0; top: 0; bottom: 0; z-index: 2; height: 100vh; height: 100dvh; transform: translateX(-102%); transition: transform var(--fd-dur-3) var(--fd-ease-out); visibility: hidden; }534 .fd-asn[data-drawer] .fd-asn-side { transform: none; visibility: visible; box-shadow: 20px 0 60px rgba(3,10,7,0.35); }535 .fd-asn-scrim { display: block; position: fixed; inset: 0; z-index: 1; background: rgba(6,14,10,0.5); opacity: 0; pointer-events: none; transition: opacity var(--fd-dur-3) linear; }536 .fd-asn[data-drawer] .fd-asn-scrim { opacity: 1; pointer-events: auto; }537 .fd-asn-collapse { display: none; }538 .fd-asn-row { height: 42px; }539 .fd-asn-sub { height: 38px; }540 .fd-asn-wsmenu { width: 100%; }541}542@media (min-width: 861px) { .fd-asn[data-rail] .fd-asn-side { overflow: visible; } }543@media (prefers-reduced-motion: reduce) {544 .fd-asn-side, .fd-asn-ind, .fd-asn-group, .fd-asn-chev, .fd-asn-carr, .fd-asn-scrim { transition: none; }545 .fd-asn-wsmenu, .fd-asn-tip { animation: none; }546}547@media (prefers-contrast: more) { .fd-asn { --fd-asn-dim: #c9d3cb; --fd-asn-line: rgba(237,231,214,0.35); } }548`;549 550/* ── Demo ─────────────────────────────────────────────────────────────── */551 552const FLOW = [553 { m: "Jun", i: 62, o: 48 }, { m: "Jul", i: 71, o: 55 }, { m: "Aug", i: 54, o: 60 }, { m: "Sep", i: 80, o: 52 },554 { m: "Oct", i: 88, o: 61 }, { m: "Nov", i: 46, o: 30 },555];556const APPROVALS = [557 { who: "Kähler Glasur A/S", what: "Glaze order 0418", amt: "DKK 48,200.00", due: "Today" },558 { who: "DSV Road", what: "Freight, week 44", amt: "DKK 12,936.50", due: "Thu" },559 { who: "Ørsted Erhverv", what: "Kiln electricity, October", amt: "DKK 31,402.19", due: "Fri" },560];561 562export function Demo() {563 return (564 <div className="fd-asn-demo">565 <style>{demoCss}</style>566 <AppSidebarNav />567 <main className="fd-asn-demo-main">568 <header className="fd-asn-demo-head">569 <div>570 <p className="fd-asn-demo-k">Payments / Approvals</p>571 <h1>3 payments need your approval</h1>572 </div>573 <button type="button" className="fd-asn-demo-btn">Approve all · DKK 92,538.69</button>574 </header>575 <section className="fd-asn-demo-grid">576 <div className="fd-asn-demo-card fd-asn-demo-bal">577 <span>Total balance, all accounts</span>578 <b>DKK 2,481,930<small>.42</small></b>579 <em>+ DKK 184,220 since 1 October</em>580 <div className="fd-asn-demo-bars" aria-label="Money in and out, last six months">581 {FLOW.map((f) => (582 <div key={f.m}>583 <i style={{ height: `${f.i}%` }} />584 <i style={{ height: `${f.o}%` }} data-out="" />585 <span>{f.m}</span>586 </div>587 ))}588 </div>589 </div>590 <div className="fd-asn-demo-card">591 <span>Awaiting approval</span>592 <ul>593 {APPROVALS.map((a) => (594 <li key={a.what}>595 <div><b>{a.who}</b><small>{a.what}</small></div>596 <div><b>{a.amt}</b><small>{a.due}</small></div>597 </li>598 ))}599 </ul>600 </div>601 </section>602 </main>603 </div>604 );605}606 607const demoCss = `608.fd-asn-demo { display: flex; min-height: 100vh; margin: 0; padding: 0; background: #f3efe5; color: #10241c; font: 400 14px/1.45 ${sans}; }609.fd-asn-demo *, .fd-asn-demo *::before, .fd-asn-demo *::after { box-sizing: border-box; }610.fd-asn-demo-main { flex: 1; min-width: 0; margin: 0; padding: 36px 40px 80px; }611.fd-asn-demo-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin: 0 0 28px; }612.fd-asn-demo-k { margin: 0 0 8px; font: 500 0.75rem/1 ${mono}; color: #5b6b61; }613.fd-asn-demo h1 { margin: 0; font: 400 2.25rem/1.05 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; letter-spacing: -0.025em; color: #10241c; }614.fd-asn-demo-btn { margin: 0; padding: 11px 16px; border: 0; border-radius: 10px; background: #10241c; color: #ede7d6; font: 600 0.8125rem/1 ${sans}; cursor: pointer; appearance: none; white-space: nowrap; }615.fd-asn-demo-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 16px; }616.fd-asn-demo-card { margin: 0; padding: 20px; border: 1px solid #e0d9c8; border-radius: 16px; background: #fbf9f3; }617.fd-asn-demo-card > span { display: block; margin: 0 0 10px; font: 500 0.75rem/1 ${mono}; letter-spacing: 0.04em; text-transform: uppercase; color: #5b6b61; }618.fd-asn-demo-bal b { display: block; font: 400 2.75rem/1 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }619.fd-asn-demo-bal small { font-size: 0.5em; color: #7a877e; }620.fd-asn-demo-bal em { display: block; margin: 8px 0 20px; font: 500 0.8125rem/1 ${mono}; font-style: normal; color: #2f7a4b; }621.fd-asn-demo-bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; height: 150px; padding: 0 0 22px; border-bottom: 1px solid #e0d9c8; }622.fd-asn-demo-bars div { position: relative; display: flex; align-items: flex-end; justify-content: center; gap: 4px; }623.fd-asn-demo-bars i { display: block; width: 14px; border-radius: 4px 4px 0 0; background: #10241c; }624.fd-asn-demo-bars i[data-out] { background: #c9a35c; }625.fd-asn-demo-bars span { position: absolute; bottom: -20px; font: 400 0.6875rem/1 ${mono}; color: #5b6b61; }626.fd-asn-demo-card ul { margin: 0; padding: 0; list-style: none; }627.fd-asn-demo-card li { display: flex; justify-content: space-between; gap: 12px; padding: 12px 0; border-top: 1px solid #ebe5d6; }628.fd-asn-demo-card li:first-child { border-top: 0; }629.fd-asn-demo-card li div { display: flex; flex-direction: column; gap: 3px; min-width: 0; }630.fd-asn-demo-card li div:last-child { text-align: right; flex: none; }631.fd-asn-demo-card li b { font: 600 0.875rem/1.2 ${sans}; font-variant-numeric: tabular-nums; }632.fd-asn-demo-card li small { font: 400 0.75rem/1.2 ${sans}; color: #5b6b61; }633@media (max-width: 860px) {634 .fd-asn-demo { display: block; }635 .fd-asn-demo-main { padding: 24px 16px 60px; }636 .fd-asn-demo-head { flex-direction: column; align-items: flex-start; }637 .fd-asn-demo h1 { font-size: 1.75rem; }638 .fd-asn-demo-grid { grid-template-columns: 1fr; }639 .fd-asn-demo-bal b { font-size: 2rem; }640}641`;More Navbars. Same file contract.
Questions about App Sidebar Nav
Run npx shadcn@latest add https://freedesignmd.com/r/app-sidebar-nav.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a collapsible icon rail. It is a free, MIT-licensed React navbars component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. Group expand animates grid-template-rows (Chrome 107+, Safari 16+, Firefox 66+); collapsed groups use inert.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


