An app sidebar with grouped links, an accent pill that glides to the active item, count badges, and a toggle that springs the panel down to an icon rail.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add sidebar-navWrites components/ui/sidebar-nav.tsx and components/styles/sidebar-nav.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SidebarNav } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SidebarNav />6 );7}Props
Accessibility
Renders a <nav> landmark labelled by the workspace title. Items are native buttons with aria-current="page" on the active one; Up/Down/Home/End move focus between items and Tab still works. The collapse toggle is a button with aria-expanded and aria-controls. When collapsed, each item keeps its accessible name through aria-label and a title tooltip, and group headings are hidden from assistive tech. Badges announce "N new". Reduced motion removes the width spring and pill glide.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/sidebar-nav.module.css";6 7export type SidebarNavIcon = "home" | "inbox" | "chart" | "folder" | "users" | "settings" | "help";8 9export interface SidebarNavItem {10 /** Unique id used for the active state */11 id: string;12 /** Visible label (also the accessible name when collapsed) */13 label: string;14 /** Built-in icon */15 icon?: SidebarNavIcon;16 /** Optional count badge */17 badge?: number;18}19 20export interface SidebarNavGroup {21 /** Group heading (hidden when collapsed) */22 label: string;23 /** Links in this group */24 items: SidebarNavItem[];25}26 27export interface SidebarNavProps {28 /** Grouped navigation links */29 groups?: SidebarNavGroup[];30 /** Controlled active item id */31 value?: string;32 /** Initially active item id (uncontrolled) */33 defaultValue?: string;34 /** Called when an item is activated */35 onValueChange?: (id: string) => void;36 /** Controlled collapsed state */37 collapsed?: boolean;38 /** Start collapsed to icons (uncontrolled) */39 defaultCollapsed?: boolean;40 /** Called when the collapse toggle is used */41 onCollapsedChange?: (collapsed: boolean) => void;42 /** Workspace title shown at the top */43 title?: string;44 /** Show count badges */45 showBadges?: boolean;46 /** Show the collapse toggle button */47 showToggle?: boolean;48 /** Expanded width in pixels */49 width?: number;50 /** Accent color for the active pill and badges */51 accentColor?: string;52 /** Optional className merged onto the root */53 className?: string;54 /** Optional inline styles merged onto the root */55 style?: React.CSSProperties;56}57 58const DEFAULT_GROUPS: SidebarNavGroup[] = [59 {60 label: "Workspace",61 items: [62 { id: "home", label: "Home", icon: "home" },63 { id: "inbox", label: "Inbox", icon: "inbox", badge: 12 },64 { id: "analytics", label: "Analytics", icon: "chart" },65 { id: "projects", label: "Projects", icon: "folder", badge: 3 },66 ],67 },68 {69 label: "Organization",70 items: [71 { id: "team", label: "Team", icon: "users" },72 { id: "settings", label: "Settings", icon: "settings" },73 { id: "help", label: "Help & support", icon: "help" },74 ],75 },76];77 78const ICON_PATHS: Record<SidebarNavIcon, string> = {79 home: "M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z",80 inbox: "M3 13h5l1.5 3h5L16 13h5M5 5h14l2 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6z",81 chart: "M4 20V10M10 20V4M16 20v-7M22 20H2",82 folder: "M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z",83 users: "M16 19v-1a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v1M9 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6M22 19v-1a4 4 0 0 0-3-3.87M16 4.13a3 3 0 0 1 0 5.74",84 settings: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.8 1.2V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 7 19.4a1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.7 1.7 0 0 0 1.2 14H1a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 2.6 7a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.7 1.7 0 0 0 7 2.6 1.7 1.7 0 0 0 8 1.1V1a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V7a1.7 1.7 0 0 0 1.5 1H23a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z",85 help: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01",86};87 88function Icon({ name }: { name: SidebarNavIcon }) {89 return (90 <svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">91 <path d={ICON_PATHS[name]} />92 </svg>93 );94}95 96export function SidebarNav({97 groups = DEFAULT_GROUPS,98 value,99 defaultValue = "inbox",100 onValueChange,101 collapsed,102 defaultCollapsed = false,103 onCollapsedChange,104 title = "Acme Inc.",105 showBadges = true,106 showToggle = true,107 width = 240,108 accentColor = "#6366f1",109 className = "",110 style,111}: SidebarNavProps) {112 const reduceMotion = useReducedMotion();113 const pillId = React.useId();114 const listId = React.useId();115 const listRef = React.useRef<HTMLDivElement>(null);116 const [activeState, setActiveState] = React.useState(defaultValue);117 const [collapsedState, setCollapsedState] = React.useState(defaultCollapsed);118 const active = value ?? activeState;119 const isCollapsed = collapsed ?? collapsedState;120 121 const select = (id: string) => {122 if (value === undefined) setActiveState(id);123 onValueChange?.(id);124 };125 126 const toggle = () => {127 const next = !isCollapsed;128 if (collapsed === undefined) setCollapsedState(next);129 onCollapsedChange?.(next);130 };131 132 // Up/Down/Home/End move focus between items (the list is also reachable with Tab).133 const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {134 const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>("[data-sidebar-item]") ?? []);135 const index = buttons.indexOf(document.activeElement as HTMLButtonElement);136 if (index === -1) return;137 const targets: Record<string, number> = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: buttons.length - 1 };138 if (!(event.key in targets)) return;139 event.preventDefault();140 buttons[(targets[event.key] + buttons.length) % buttons.length]?.focus();141 };142 143 const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 };144 145 return (146 <motion.nav147 aria-label={title || "Sidebar"}148 className={`${styles.root} ${className}`.trim()}149 data-collapsed={isCollapsed || undefined}150 animate={{ width: isCollapsed ? 64 : width }}151 initial={false}152 transition={spring}153 style={{ "--mf-sn-accent": accentColor, ...style } as React.CSSProperties}154 >155 <div className={styles.header}>156 <span className={styles.logo} aria-hidden="true" />157 {!isCollapsed && <span className={styles.title}>{title}</span>}158 {showToggle && (159 <button160 type="button"161 className={styles.toggle}162 onClick={toggle}163 aria-expanded={!isCollapsed}164 aria-controls={listId}165 aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}166 >167 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" aria-hidden="true">168 <rect x="3" y="4" width="18" height="16" rx="2" />169 <path d="M9 4v16" />170 </svg>171 </button>172 )}173 </div>174 175 <div id={listId} ref={listRef} className={styles.groups} onKeyDown={onKeyDown}>176 {groups.map((group) => (177 <div key={group.label} className={styles.group}>178 <div className={styles.groupLabel} aria-hidden={isCollapsed || undefined}>179 {isCollapsed ? <span className={styles.groupRule} /> : group.label}180 </div>181 <ul className={styles.list}>182 {group.items.map((item) => {183 const isActive = item.id === active;184 return (185 <li key={item.id}>186 <button187 type="button"188 data-sidebar-item=""189 className={styles.item}190 aria-current={isActive ? "page" : undefined}191 aria-label={isCollapsed ? item.label : undefined}192 title={isCollapsed ? item.label : undefined}193 onClick={() => select(item.id)}194 >195 {isActive && <motion.span layoutId={`${pillId}-pill`} className={styles.pill} transition={spring} aria-hidden="true" />}196 {item.icon && <Icon name={item.icon} />}197 {!isCollapsed && <span className={styles.label}>{item.label}</span>}198 {showBadges && item.badge !== undefined && (199 <span className={isCollapsed ? styles.badgeDot : styles.badge} aria-label={`${item.badge} new`}>200 {isCollapsed ? null : item.badge}201 </span>202 )}203 </button>204 </li>205 );206 })}207 </ul>208 </div>209 ))}210 </div>211 </motion.nav>212 );213}