UINavigationmotion/react

SidebarNav

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 props
240

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add sidebar-nav

Writes 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.

example.tsx
1import { SidebarNav } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SidebarNav />6  );7}

Props

PropTypeDefault
groups

Grouped links: { label, items: { id, label, icon?, badge? }[] }.

SidebarNavGroup[]Workspace + Organization groups
value

Controlled active item id.

stringundefined
defaultValue

Initially active item id (uncontrolled).

string'inbox'
onValueChange

Called when an item is activated.

(id: string) => voidundefined
collapsed

Controlled collapsed state.

booleanundefined
defaultCollapsed

Start collapsed to the icon rail (uncontrolled).

booleanfalse
onCollapsedChange

Called when the collapse toggle is used.

(collapsed: boolean) => voidundefined
title

Workspace title; also the nav landmark's label.

string'Acme Inc.'
showBadges

Show count badges (a dot when collapsed).

booleantrue
showToggle

Show the collapse toggle button.

booleantrue
width

Expanded width in pixels (collapsed is 64).

number240
accentColor

Active pill, icon, and badge color.

string'#6366f1'
className

Merged onto the root.

string''
style

Merged onto the root.

React.CSSPropertiesundefined

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

components/ui/sidebar-nav.tsx
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}