# Sidebar

> App sidebar with groups, collapse-to-rail and an active tube marker.

Category: Navigation. Docs: https://neonkit.heyaadi.com/components/sidebar

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/sidebar.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/sidebar`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarInset,
  SidebarItem,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar"

<SidebarProvider className="min-h-svh">
  <Sidebar>
    <SidebarContent>
      <SidebarGroup label="Workspace">
        <SidebarItem icon={<Inbox />} href="/inbox" badge={3} active>
          Inbox
        </SidebarItem>
        <SidebarItem icon={<Folder />} href="/projects">Projects</SidebarItem>
      </SidebarGroup>
    </SidebarContent>
  </Sidebar>
  <SidebarInset>
    <SidebarTrigger />
    …
  </SidebarInset>
</SidebarProvider>
```

## Examples

### App shell

Click around and the lit tube glides to the active item. Press ⌘B or Ctrl+B to collapse to the rail, then hover an icon for its label.

```tsx
"use client";

import * as React from "react";
import {
  ChevronsUpDown,
  CircleDashed,
  CircleDot,
  CircleCheck,
  Inbox,
  MapIcon,
  Orbit,
  Plus,
  RefreshCcw,
  SquareKanban,
  UserRoundCheck,
} from "@/components/ui/icons";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
  SidebarInset,
  SidebarItem,
  SidebarProvider,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";

type Status = "todo" | "progress" | "review" | "done";
type Row = { id: string; title: string; meta: string; status: Status };

const pages: Record<string, { title: string; summary: string; rows: Row[] }> = {
  inbox: {
    title: "Inbox",
    summary: "3 unread",
    rows: [
      { id: "ORB-412", title: "Rail loses focus after collapse", meta: "4m", status: "progress" },
      { id: "ORB-398", title: "Ship compact pagination", meta: "1h", status: "todo" },
      { id: "ORB-377", title: "Audit contrast in dark tables", meta: "3h", status: "review" },
      { id: "ORB-351", title: "Stepper connector stutters", meta: "1d", status: "done" },
    ],
  },
  issues: {
    title: "My issues",
    summary: "4 assigned",
    rows: [
      { id: "ORB-415", title: "Animate width on collapse", meta: "Today", status: "progress" },
      { id: "ORB-409", title: "Shortcut clashes with editors", meta: "Today", status: "review" },
      { id: "ORB-398", title: "Ship compact pagination", meta: "Fri", status: "todo" },
      { id: "ORB-362", title: "Document the mobile drawer", meta: "Mon", status: "done" },
    ],
  },
  projects: {
    title: "Projects",
    summary: "4 active",
    rows: [
      { id: "NK", title: "Neon v1.1", meta: "68%", status: "progress" },
      { id: "DS", title: "Docs search", meta: "41%", status: "progress" },
      { id: "MS", title: "Marketing site", meta: "90%", status: "review" },
      { id: "RC", title: "Registry CLI", meta: "12%", status: "todo" },
    ],
  },
  cycles: {
    title: "Cycles",
    summary: "Cycle 24 ends Friday",
    rows: [
      { id: "C25", title: "Cycle 25", meta: "Mar 10", status: "todo" },
      { id: "C24", title: "Cycle 24", meta: "62%", status: "progress" },
      { id: "C23", title: "Cycle 23", meta: "94%", status: "done" },
      { id: "C22", title: "Cycle 22", meta: "100%", status: "done" },
    ],
  },
  roadmap: {
    title: "Roadmap",
    summary: "2026",
    rows: [
      { id: "Q1", title: "Navigation components", meta: "On track", status: "progress" },
      { id: "Q2", title: "Data display", meta: "Planned", status: "todo" },
      { id: "Q2", title: "Theming playground", meta: "At risk", status: "review" },
      { id: "Q3", title: "Figma kit", meta: "Planned", status: "todo" },
    ],
  },
  platform: {
    title: "Platform",
    summary: "Team · 6 members",
    rows: [
      { id: "PLT-88", title: "Cache registry JSON at the edge", meta: "2d", status: "progress" },
      { id: "PLT-84", title: "Upgrade to motion 14", meta: "3d", status: "done" },
      { id: "PLT-79", title: "Flaky visual tests on CI", meta: "5d", status: "todo" },
    ],
  },
  design: {
    title: "Design",
    summary: "Team · 4 members",
    rows: [
      { id: "DES-51", title: "Glow tokens for light surfaces", meta: "1d", status: "review" },
      { id: "DES-47", title: "Rail icon set", meta: "2d", status: "progress" },
      { id: "DES-42", title: "Stepper empty states", meta: "4d", status: "todo" },
    ],
  },
};

const statusIcons: Record<Status, React.ReactNode> = {
  todo: <CircleDashed aria-label="Todo" className="size-4 text-white/35" />,
  progress: <CircleDot aria-label="In progress" className="size-4 text-[rgb(251_191_36)]" />,
  review: <RefreshCcw aria-label="In review" className="size-4 text-[rgb(168_85_247)]" />,
  done: <CircleCheck aria-label="Done" className="size-4 text-[rgb(52_211_153)]" />,
};

function TeamSwatch({ color }: { color: string }) {
  return <span className="size-2.5 rounded-[3px]" style={{ background: color, boxShadow: `0 0 6px ${color}` }} />;
}

function Workspace() {
  const { collapsed } = useSidebar();
  return (
    <button
      type="button"
      className="flex h-11 w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-lg px-1.5 text-left outline-none transition-colors duration-150 hover:bg-white/[0.04] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
    >
      <span className="grid size-7 shrink-0 place-items-center rounded-lg border border-[rgb(var(--neon)/0.45)] bg-[rgb(var(--neon)/0.1)] text-[rgb(var(--neon))] shadow-[0_0_12px_rgb(var(--neon)/0.25)]">
        <Orbit aria-hidden className="size-4" />
      </span>
      <span className={cn("grid min-w-0 flex-1 transition-opacity duration-200", collapsed && "md:opacity-0")}>
        <span className="truncate text-sm font-medium text-white/90">Orbit</span>
        <span className="truncate text-xs text-white/50">Lumen Labs</span>
      </span>
      <ChevronsUpDown
        aria-hidden
        className={cn("size-3.5 shrink-0 text-white/35 transition-opacity duration-200", collapsed && "md:opacity-0")}
      />
    </button>
  );
}

function Account() {
  const { collapsed } = useSidebar();
  return (
    <button
      type="button"
      className="flex h-11 w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-lg px-1.5 text-left outline-none transition-colors duration-150 hover:bg-white/[0.04] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
    >
      <Avatar alt="" fallback="MO" size="sm" status="online" />
      <span className="sr-only">Account: </span>
      <span className={cn("grid min-w-0 flex-1 transition-opacity duration-200", collapsed && "md:opacity-0")}>
        <span className="truncate text-sm text-white/90">Mira Okafor</span>
        <span className="truncate text-xs text-white/50">mira@lumenlabs.dev</span>
      </span>
      <ChevronsUpDown
        aria-hidden
        className={cn("size-3.5 shrink-0 text-white/35 transition-opacity duration-200", collapsed && "md:opacity-0")}
      />
    </button>
  );
}

export default function SidebarDemo() {
  const [active, setActive] = React.useState("projects");
  const page = pages[active];

  const item = (id: string, label: string, icon: React.ReactNode, badge?: React.ReactNode) => (
    <SidebarItem icon={icon} active={active === id} badge={badge} onClick={() => setActive(id)}>
      {label}
    </SidebarItem>
  );

  return (
    <SidebarProvider className="h-[520px] w-full max-w-3xl overflow-hidden rounded-xl border border-white/10 bg-[#05060a]">
      <Sidebar>
        <SidebarHeader>
          <Workspace />
        </SidebarHeader>
        <SidebarContent>
          <SidebarGroup>
            {item("inbox", "Inbox", <Inbox />, 3)}
            {item("issues", "My issues", <UserRoundCheck />)}
          </SidebarGroup>
          <SidebarGroup label="Workspace">
            {item("projects", "Projects", <SquareKanban />)}
            {item("cycles", "Cycles", <RefreshCcw />)}
            {item("roadmap", "Roadmap", <MapIcon />)}
          </SidebarGroup>
          <SidebarGroup label="Teams">
            {item("platform", "Platform", <TeamSwatch color="rgb(52 211 153)" />)}
            {item("design", "Design", <TeamSwatch color="rgb(255 95 162)" />)}
          </SidebarGroup>
        </SidebarContent>
        <SidebarFooter>
          <Account />
        </SidebarFooter>
      </Sidebar>

      <SidebarInset className="overflow-hidden">
        <header className="flex h-[60px] shrink-0 items-center gap-2 border-b border-white/[0.08] px-3">
          <SidebarTrigger />
          <span aria-hidden className="h-4 w-px bg-white/10" />
          <h3 className="min-w-0 truncate px-1 text-sm font-medium text-white/90">{page.title}</h3>
          <span className="ml-auto hidden items-center gap-1 text-xs text-white/35 lg:flex">
            <Kbd size="sm" listen="Meta">
              ⌘
            </Kbd>
            <Kbd size="sm" listen="b">
              B
            </Kbd>
          </span>
          <Button size="sm" variant="soft" className="ml-auto lg:ml-2">
            <Plus /> New
          </Button>
        </header>

        <div className="min-h-0 flex-1 overflow-y-auto p-3">
          <p className="px-2 pb-2 pt-1 text-xs text-white/50">{page.summary}</p>
          <ul className="grid gap-px">
            {page.rows.map((row) => (
              <li
                key={`${active}-${row.id}-${row.title}`}
                className="flex h-10 items-center gap-3 rounded-lg px-2 text-sm transition-colors duration-150 hover:bg-white/[0.03]"
              >
                {statusIcons[row.status]}
                <span className="w-14 shrink-0 truncate font-mono text-xs text-white/45">{row.id}</span>
                <span className="min-w-0 flex-1 truncate text-white/80">{row.title}</span>
                {row.meta.endsWith("%") && (
                  <span aria-hidden className="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-white/[0.08] sm:block">
                    <span
                      className="block h-full rounded-full bg-white/40"
                      style={{ width: row.meta }}
                    />
                  </span>
                )}
                <span className="min-w-10 shrink-0 text-right text-xs tabular-nums text-white/50">{row.meta}</span>
              </li>
            ))}
          </ul>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
}
```

### Starts as a rail

defaultCollapsed opens on the 56px rail. Badges turn into a dot on the icon, and the hover label carries the count.

```tsx
"use client";

import * as React from "react";
import { Archive, Inbox, Mail, PenLine, Send, Settings, Star, Tag, Trash2 } from "@/components/ui/icons";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
  SidebarInset,
  SidebarItem,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";

const folders = [
  { id: "inbox", label: "Inbox", icon: <Inbox />, badge: 12 },
  { id: "starred", label: "Starred", icon: <Star /> },
  { id: "sent", label: "Sent", icon: <Send /> },
  { id: "drafts", label: "Drafts", icon: <PenLine />, badge: 2 },
  { id: "archive", label: "Archive", icon: <Archive /> },
  { id: "trash", label: "Trash", icon: <Trash2 /> },
];

const labels = [
  { id: "clients", label: "Clients", icon: <Tag className="text-[rgb(52_211_153)]" /> },
  { id: "receipts", label: "Receipts", icon: <Tag className="text-[rgb(251_191_36)]" /> },
];

const threads = [
  { from: "Ines Duarte", subject: "Rail labels look great on the 13-inch", time: "9:41" },
  { from: "Stripe", subject: "Your receipt from Lumen Labs #2291-4410", time: "8:02" },
  { from: "Theo Lambert", subject: "Re: moving the review to Thursday", time: "Yesterday" },
  { from: "Vercel", subject: "Deployment aurora-web ready", time: "Yesterday" },
  { from: "Ada Moreau", subject: "Contract draft, v3 with redlines", time: "Mon" },
];

export default function SidebarRail() {
  const [active, setActive] = React.useState("inbox");
  const current = [...folders, ...labels].find((folder) => folder.id === active);

  return (
    <SidebarProvider
      defaultCollapsed
      shortcut={false}
      className="h-[480px] w-full max-w-3xl overflow-hidden rounded-xl border border-white/10 bg-[#05060a]"
    >
      <Sidebar>
        <SidebarHeader>
          <span className="grid size-10 shrink-0 place-items-center text-[rgb(var(--neon))] [filter:drop-shadow(0_0_6px_rgb(var(--neon)/0.6))]">
            <Mail aria-hidden className="size-5" />
          </span>
          <span className="truncate text-sm font-medium text-white/90">Postbox</span>
        </SidebarHeader>
        <SidebarContent aria-label="Mailboxes">
          <SidebarGroup>
            {folders.map((folder) => (
              <SidebarItem
                key={folder.id}
                icon={folder.icon}
                badge={folder.badge}
                active={active === folder.id}
                onClick={() => setActive(folder.id)}
              >
                {folder.label}
              </SidebarItem>
            ))}
          </SidebarGroup>
          <SidebarGroup label="Labels">
            {labels.map((label) => (
              <SidebarItem
                key={label.id}
                icon={label.icon}
                active={active === label.id}
                onClick={() => setActive(label.id)}
              >
                {label.label}
              </SidebarItem>
            ))}
          </SidebarGroup>
        </SidebarContent>
        <SidebarFooter>
          <SidebarGroup>
            <SidebarItem icon={<Settings />}>Settings</SidebarItem>
          </SidebarGroup>
        </SidebarFooter>
      </Sidebar>

      <SidebarInset className="overflow-hidden">
        <header className="flex h-14 shrink-0 items-center gap-2 border-b border-white/[0.08] px-3">
          <SidebarTrigger />
          <h3 className="text-sm font-medium text-white/90">{current?.label}</h3>
        </header>
        <ul className="min-h-0 flex-1 divide-y divide-white/[0.05] overflow-y-auto">
          {threads.map((thread) => (
            <li key={thread.subject} className="grid gap-0.5 px-4 py-3 transition-colors duration-150 hover:bg-white/[0.02]">
              <div className="flex items-baseline justify-between gap-3">
                <span className="truncate text-sm text-white/90">{thread.from}</span>
                <span className="shrink-0 text-xs text-white/40">{thread.time}</span>
              </div>
              <p className="truncate text-xs text-white/55">{thread.subject}</p>
            </li>
          ))}
        </ul>
      </SidebarInset>
    </SidebarProvider>
  );
}
```

### Settings navigation

collapsible="none" keeps the sidebar open on wide screens. Below md it is still a drawer.

```tsx
"use client";

import * as React from "react";
import { Bell, CreditCard, KeyRound, Puzzle, ShieldCheck, UserRound, UsersRound, Webhook } from "@/components/ui/icons";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarInset,
  SidebarItem,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";

const sections = {
  profile: {
    label: "Profile",
    icon: <UserRound />,
    rows: [
      ["Display name", "Mira Okafor"],
      ["Email", "mira@lumenlabs.dev"],
      ["Timezone", "Europe/London"],
    ],
  },
  notifications: {
    label: "Notifications",
    icon: <Bell />,
    rows: [
      ["Failed deploys", "Email and in-app"],
      ["Mentions", "In-app"],
      ["Weekly report", "Off"],
    ],
  },
  security: {
    label: "Security",
    icon: <ShieldCheck />,
    rows: [
      ["Passkeys", "2 registered"],
      ["Two-factor", "On"],
      ["Sessions", "3 active"],
    ],
  },
  billing: {
    label: "Billing",
    icon: <CreditCard />,
    rows: [
      ["Plan", "Pro trial, 9 days left"],
      ["Payment", "Visa ending 4242"],
      ["Invoices", "Monthly, by email"],
    ],
  },
  members: {
    label: "Members",
    icon: <UsersRound />,
    rows: [
      ["Members", "8"],
      ["Pending invites", "2"],
      ["Default role", "Developer"],
    ],
  },
  integrations: {
    label: "Integrations",
    icon: <Puzzle />,
    rows: [
      ["GitHub", "lumenlabs"],
      ["Slack", "#deploys"],
      ["Linear", "Not connected"],
    ],
  },
  tokens: {
    label: "API tokens",
    icon: <KeyRound />,
    rows: [
      ["ci-deploy", "Used 2 hours ago"],
      ["local-scripts", "Never used"],
    ],
  },
  webhooks: {
    label: "Webhooks",
    icon: <Webhook />,
    rows: [
      ["deploy.succeeded", "hooks.lumenlabs.dev"],
      ["deploy.failed", "hooks.lumenlabs.dev"],
    ],
  },
};

type Section = keyof typeof sections;

export default function SidebarSettings() {
  const [active, setActive] = React.useState<Section>("profile");
  const section = sections[active];

  const item = (id: Section, badge?: React.ReactNode) => (
    <SidebarItem icon={sections[id].icon} badge={badge} active={active === id} onClick={() => setActive(id)}>
      {sections[id].label}
    </SidebarItem>
  );

  return (
    <SidebarProvider
      shortcut={false}
      className="h-[420px] w-full max-w-3xl overflow-hidden rounded-xl border border-white/10 bg-[#05060a]"
    >
      <Sidebar collapsible="none">
        <SidebarContent aria-label="Settings">
          <SidebarGroup label="Account">
            {item("profile")}
            {item("notifications")}
            {item("security")}
            {item("billing", "Trial")}
          </SidebarGroup>
          <SidebarGroup label="Workspace">
            {item("members", 8)}
            {item("integrations")}
            {item("tokens")}
            {item("webhooks")}
          </SidebarGroup>
        </SidebarContent>
      </Sidebar>

      <SidebarInset className="overflow-y-auto px-6 py-5">
        <div className="flex items-center gap-2">
          {/* The sidebar is a drawer below md, so the trigger only shows there. */}
          <SidebarTrigger className="-ml-2 md:hidden" />
          <h3 className="text-base font-medium text-white/90">{section.label}</h3>
        </div>
        <dl className="mt-4 grid max-w-md">
          {section.rows.map(([term, value]) => (
            <div key={term} className="flex items-baseline justify-between gap-4 border-b border-white/[0.06] py-3 text-sm">
              <dt className="text-white/55">{term}</dt>
              <dd className="truncate text-right text-white/85">{value}</dd>
            </div>
          ))}
        </dl>
      </SidebarInset>
    </SidebarProvider>
  );
}
```

## API reference

### SidebarProvider

Holds the state and renders a flex row: put Sidebar and SidebarInset inside. Give it min-h-svh for a page shell, or a fixed height for a framed panel.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `collapsed` | `boolean` | — | Rail state when controlled. |
| `defaultCollapsed` | `boolean` | `false` | Initial rail state when uncontrolled. |
| `onCollapsedChange` | `(collapsed: boolean) => void` | — | Called when the rail is toggled. |
| `shortcut` | `string \| false` | `"b"` | ⌘ or Ctrl plus this key toggles the sidebar. Ignored inside contenteditable. Pass false to turn it off. |

### Sidebar

The panel. From md up it sits in the layout, sticky, as tall as the viewport or the provider, whichever is smaller. Below md it is an off-canvas drawer. It sets data-state="collapsed" | "expanded" and the group/sidebar class for styling your own content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"left" \| "right"` | `"left"` | Which edge it sits on. For the right, render it after SidebarInset. |
| `collapsible` | `"rail" \| "none"` | `"rail"` | Allow collapsing to the 56px icon rail. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### SidebarHeader / SidebarFooter

Fixed rows at the top and bottom. They clip their content to the rail; fade your own text with useSidebar().collapsed, e.g. collapsed && "md:opacity-0".

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Classes for the row. |

### SidebarContent

The scrolling middle. Renders a nav labelled "Sidebar".

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Sidebar"` | Name of the navigation landmark. |

### SidebarGroup

A list of items with an optional heading. Items must live in a group, including in the footer.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` | — | Heading above the items. In the rail it becomes a short divider. |

### SidebarItem

A row in a group. Renders a link with href, a button without, or your own element with asChild.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `ReactNode` | — | 16px icon. It stays put while the sidebar collapses. |
| `href` | `string` | — | Renders an a instead of a button. |
| `active` | `boolean` | `false` | Marks the current page: aria-current="page" and the lit tube. |
| `badge` | `ReactNode` | — | Count or short tag at the end of the row. In the rail it becomes a dot on the icon. |
| `tooltip` | `ReactNode` | — | Text for the rail hover label. Defaults to the item's label. |
| `disabled` | `boolean` | `false` | Dims the row and blocks clicks. |
| `onClick` | `(event) => void` | — | Below md, a click also closes the drawer unless you call event.preventDefault(). |
| `asChild` | `boolean` | `false` | Merge onto the single child, such as your router's Link. The child's children become the label. |

### SidebarTrigger

An icon button that collapses or expands the sidebar, or opens the drawer below md. Children replace the icon.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color` | `NeonColor` | `inherited` | Palette name or hex for the focus ring. |

### SidebarInset

The page next to the sidebar. A flex column that takes the remaining width. While the drawer is open it is inert.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Classes for the page column, e.g. overflow-y-auto. |

### useSidebar()

Reads the nearest SidebarProvider. Throws outside one.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `collapsed` | `boolean` | — | Whether the sidebar is on the rail (md and up). |
| `setCollapsed` | `(collapsed: boolean) => void` | — | Collapse or expand. |
| `toggle` | `() => void` | — | What the trigger and the shortcut call: the rail on desktop, the drawer below md. |
| `isMobile` | `boolean` | — | True below md, where the sidebar is a drawer. False during server rendering. |
| `openMobile` | `boolean` | — | Whether the drawer is open. |
| `setOpenMobile` | `(open: boolean) => void` | — | Open or close the drawer. |

## Accessibility

- The trigger is a button named "Toggle sidebar" with aria-expanded, aria-controls pointing at the panel, and aria-keyshortcuts for ⌘B and Ctrl+B.
- The active item has aria-current="page". The tube is decorative and hidden from screen readers.
- On the rail the labels fade but stay in the accessibility tree, so every item keeps its name. The hover label is visual only and also appears on keyboard focus.
- The hover label closes on Escape, on scroll and when the pointer leaves the item.
- Below md the sidebar is a modal drawer: focus moves into it and Tab cycles inside it, Escape and the backdrop close it, the page behind is inert and stops scrolling, and focus returns to the trigger. The drawer renders into document.body, so no parent's z-index or transform can trap it.
- The width animation and the glide respect reduced motion.

## Source

`components/ui/sidebar.tsx`

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { PanelLeft } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/** The single element in `node`. Server Components can pass it as a lazy reference, which Children resolves. */
function resolveElement<P = Record<string, unknown>>(node: React.ReactNode): React.ReactElement<P> | null {
  const [child] = React.Children.toArray(node);
  return React.isValidElement<P>(child) ? child : null;
}

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

const EASE = [0.23, 1, 0.32, 1] as const;
const GLIDE = { duration: 0.3, ease: EASE };
const MOBILE_QUERY = "(max-width: 767.98px)";
// React 19 treats inert as a boolean; React 18 only renders it from a string.
const INERT = (Number.parseInt(React.version, 10) >= 19 ? { inert: true } : { inert: "" }) as React.HTMLAttributes<HTMLDivElement>;
const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

type AnyRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: AnyRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.RefObject<T | null>).current = value;
}

// React 19 moved `ref` into props; reading `element.ref` there logs a warning.
function getElementRef<T>(element: React.ReactElement): AnyRef<T> {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<T> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<T> }).ref;
}

function subscribeToViewport(callback: () => void) {
  const query = window.matchMedia(MOBILE_QUERY);
  query.addEventListener?.("change", callback);
  return () => query.removeEventListener?.("change", callback);
}

/** Below `md` the sidebar is an off-canvas drawer. The server renders the desktop layout. */
function useIsMobile() {
  return React.useSyncExternalStore(
    subscribeToViewport,
    () => window.matchMedia(MOBILE_QUERY).matches,
    () => false,
  );
}

const subscribeNever = () => () => {};

function useMounted() {
  return React.useSyncExternalStore(subscribeNever, () => true, () => false);
}

function isFocusVisible(element: Element) {
  try {
    return element.matches(":focus-visible");
  } catch {
    return true;
  }
}

function matchesShortcut(event: KeyboardEvent, key: string) {
  const target = key.toLowerCase();
  if (event.key.toLowerCase() === target) return true;
  // Non-Latin layouts and Option rewrite event.key, so fall back to the physical key.
  if (/^[a-z]$/.test(target)) return event.code === `Key${target.toUpperCase()}`;
  return false;
}

type SidebarContextValue = {
  collapsed: boolean;
  setCollapsed: (collapsed: boolean) => void;
  /** Collapses or expands the sidebar; below `md` it opens or closes the drawer instead. */
  toggle: () => void;
  isMobile: boolean;
  openMobile: boolean;
  setOpenMobile: (open: boolean) => void;
  sidebarId: string;
  shortcut: string | false;
};

const SidebarContext = React.createContext<SidebarContextValue | null>(null);

function useSidebarContext(component: string) {
  const context = React.useContext(SidebarContext);
  if (!context) throw new Error(`<${component}> must be used inside <SidebarProvider>.`);
  return context;
}

/** Reads and controls the nearest SidebarProvider. */
function useSidebar() {
  const context = React.useContext(SidebarContext);
  if (!context) throw new Error("useSidebar must be used inside <SidebarProvider>.");
  return context;
}

type SidebarInstanceValue = {
  layoutId: string;
  side: "left" | "right";
  /** Collapsed to the icon rail (md and up). */
  rail: boolean;
  hintsEnabled: boolean;
  reduceMotion: boolean;
  showHint: (element: HTMLElement, label: React.ReactNode, badge?: React.ReactNode) => void;
  hideHint: (immediate?: boolean) => void;
};

const SidebarInstanceContext = React.createContext<SidebarInstanceValue | null>(null);

function useSidebarInstance(component: string) {
  const context = React.useContext(SidebarInstanceContext);
  if (!context) throw new Error(`<${component}> must be used inside <Sidebar>.`);
  return context;
}

export interface SidebarProviderProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Rail state when controlled. */
  collapsed?: boolean;
  /** Initial rail state when uncontrolled. */
  defaultCollapsed?: boolean;
  onCollapsedChange?: (collapsed: boolean) => void;
  /** ⌘ (or Ctrl) plus this key toggles the sidebar. Pass false to turn the shortcut off. */
  shortcut?: string | false;
}

const SidebarProvider = React.forwardRef<HTMLDivElement, SidebarProviderProps>(
  (
    {
      className,
      collapsed: collapsedProp,
      defaultCollapsed = false,
      onCollapsedChange,
      shortcut = "b",
      ...props
    },
    ref,
  ) => {
    const sidebarId = `${React.useId()}sidebar`;
    const isMobile = useIsMobile();
    const [uncontrolled, setUncontrolled] = React.useState(defaultCollapsed);
    const [openMobile, setOpenMobile] = React.useState(false);
    const collapsed = collapsedProp ?? uncontrolled;
    // A drawer left open while the window widens past md shouldn't pop open again on the way back.
    if (!isMobile && openMobile) setOpenMobile(false);

    const setCollapsed = React.useCallback(
      (next: boolean) => {
        if (next === collapsed) return;
        if (collapsedProp === undefined) setUncontrolled(next);
        onCollapsedChange?.(next);
      },
      [collapsed, collapsedProp, onCollapsedChange],
    );

    const toggle = React.useCallback(() => {
      if (isMobile) setOpenMobile((open) => !open);
      else setCollapsed(!collapsed);
    }, [isMobile, collapsed, setCollapsed]);

    // The listener stays subscribed once; it always calls the latest toggle.
    const toggleRef = React.useRef(toggle);
    React.useEffect(() => {
      toggleRef.current = toggle;
    }, [toggle]);

    React.useEffect(() => {
      if (!shortcut) return;
      const onKeyDown = (event: KeyboardEvent) => {
        if (event.defaultPrevented || event.repeat || event.altKey || event.shiftKey) return;
        if (!(event.metaKey || event.ctrlKey) || !matchesShortcut(event, shortcut)) return;
        // Rich-text editors use ⌘B for bold.
        const target = event.target;
        if (target instanceof Element && target.closest('[contenteditable]:not([contenteditable="false"])')) return;
        event.preventDefault();
        toggleRef.current();
      };
      window.addEventListener("keydown", onKeyDown);
      return () => window.removeEventListener("keydown", onKeyDown);
    }, [shortcut]);

    const context = React.useMemo<SidebarContextValue>(
      () => ({
        collapsed,
        setCollapsed,
        toggle,
        isMobile,
        openMobile,
        setOpenMobile,
        sidebarId,
        shortcut,
      }),
      [collapsed, setCollapsed, toggle, isMobile, openMobile, sidebarId, shortcut],
    );

    return (
      <SidebarContext.Provider value={context}>
        <div ref={ref} className={cn("flex w-full", className)} {...props} />
      </SidebarContext.Provider>
    );
  },
);
SidebarProvider.displayName = "SidebarProvider";

type Hint = {
  label: React.ReactNode;
  badge?: React.ReactNode;
  x: number;
  y: number;
  neon: string;
};

export interface SidebarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  side?: "left" | "right";
  /** "rail" lets the sidebar collapse to a 56px icon rail; "none" keeps it open. */
  collapsible?: "rail" | "none";
}

const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
  (
    {
      className,
      style,
      color,
      side = "left",
      collapsible = "rail",
      children,
      "aria-label": ariaLabel,
      ...props
    },
    ref,
  ) => {
    const { collapsed, isMobile, openMobile, setOpenMobile, sidebarId } = useSidebarContext("Sidebar");
    const panelRef = React.useRef<HTMLDivElement>(null);
    const mergedRef = React.useCallback(
      (node: HTMLDivElement | null) => {
        setRef(ref, node);
        panelRef.current = node;
      },
      [ref],
    );
    const instanceId = React.useId();
    const reduceMotion = useReducedMotion() ?? false;
    const mounted = useMounted();

    const rail = collapsible === "rail" && collapsed;
    const hintsEnabled = rail && !isMobile;
    const drawerOpen = isMobile && openMobile;

    const [hint, setHint] = React.useState<Hint | null>(null);
    const hideTimer = React.useRef<number | undefined>(undefined);

    // A stale hint must not reappear the next time the sidebar collapses.
    const [hintsWereEnabled, setHintsWereEnabled] = React.useState(hintsEnabled);
    if (hintsWereEnabled !== hintsEnabled) {
      setHintsWereEnabled(hintsEnabled);
      setHint(null);
    }
    const visibleHint = hintsEnabled ? hint : null;
    const hintOpen = visibleHint !== null;

    React.useEffect(() => () => window.clearTimeout(hideTimer.current), []);

    const showHint = React.useCallback(
      (element: HTMLElement, label: React.ReactNode, badge?: React.ReactNode) => {
        window.clearTimeout(hideTimer.current);
        const panel = panelRef.current;
        if (!panel) return;
        const item = element.getBoundingClientRect();
        const bounds = panel.getBoundingClientRect();
        setHint({
          label,
          badge,
          x: side === "left" ? bounds.right + 10 : bounds.left - 10,
          y: item.top + item.height / 2,
          // The hint is portaled out of the sidebar, so it carries the color with it.
          neon: getComputedStyle(panel).getPropertyValue("--neon").trim(),
        });
      },
      [side],
    );

    const hideHint = React.useCallback((immediate = false) => {
      window.clearTimeout(hideTimer.current);
      if (immediate) setHint(null);
      // A short grace period lets the hint glide to the next item instead of blinking.
      else hideTimer.current = window.setTimeout(() => setHint(null), 80);
    }, []);

    React.useEffect(() => {
      if (!hintOpen) return;
      const close = () => setHint(null);
      const onKeyDown = (event: KeyboardEvent) => {
        if (event.key === "Escape") close();
      };
      window.addEventListener("scroll", close, true);
      window.addEventListener("resize", close);
      document.addEventListener("keydown", onKeyDown);
      return () => {
        window.removeEventListener("scroll", close, true);
        window.removeEventListener("resize", close);
        document.removeEventListener("keydown", onKeyDown);
      };
    }, [hintOpen]);

    // The mobile drawer behaves like a modal: focus moves in, Escape closes, the page stops scrolling.
    React.useEffect(() => {
      if (!drawerOpen) return;
      const panel = panelRef.current;
      const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null;
      // Wait two frames: the panel is still visibility:hidden until its transition starts.
      let frame = requestAnimationFrame(() => {
        frame = requestAnimationFrame(() => {
          const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
          (first ?? panel)?.focus({ preventScroll: true });
        });
      });

      const onKeyDown = (event: KeyboardEvent) => {
        if (event.defaultPrevented || !panel) return;
        if (event.key === "Escape") {
          event.preventDefault();
          setOpenMobile(false);
          return;
        }
        // SidebarInset is inert, but the rest of the page may not be: keep Tab inside the drawer.
        if (event.key !== "Tab") return;
        const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
          (element) =>
            !element.closest("[hidden], [inert]") &&
            (typeof element.checkVisibility !== "function" || element.checkVisibility()),
        );
        if (focusable.length === 0) {
          event.preventDefault();
          panel.focus({ preventScroll: true });
          return;
        }
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        const active = document.activeElement;
        const outside = !(active instanceof Node) || !panel.contains(active);
        if (event.shiftKey && (active === first || active === panel || outside)) {
          event.preventDefault();
          last.focus();
        } else if (!event.shiftKey && (active === last || outside)) {
          event.preventDefault();
          first.focus();
        }
      };
      const overflow = document.body.style.overflow;
      document.body.style.overflow = "hidden";
      document.addEventListener("keydown", onKeyDown);
      return () => {
        cancelAnimationFrame(frame);
        document.removeEventListener("keydown", onKeyDown);
        document.body.style.overflow = overflow;
        if (restore?.isConnected) restore.focus({ preventScroll: true });
      };
    }, [drawerOpen, setOpenMobile]);

    const instance = React.useMemo<SidebarInstanceValue>(
      () => ({ layoutId: instanceId, side, rail, hintsEnabled, reduceMotion, showHint, hideHint }),
      [instanceId, side, rail, hintsEnabled, reduceMotion, showHint, hideHint],
    );

    const nudge = reduceMotion ? 0 : side === "left" ? -4 : 4;

    // Below md the drawer renders into document.body, so an isolated, transformed or z-indexed
    // ancestor (a card, a preview frame) can't trap it under a sticky header. The portal leaves the
    // accent behind, so it's read from where the Sidebar is mounted.
    const portaled = isMobile && mounted;
    const anchorRef = React.useRef<HTMLSpanElement>(null);
    const [inheritedAccent, setInheritedAccent] = React.useState<string | undefined>(undefined);
    React.useEffect(() => {
      const anchor = anchorRef.current;
      if (!portaled || color || !anchor) return;
      setInheritedAccent(getComputedStyle(anchor).getPropertyValue("--neon-accent").trim() || undefined);
    }, [portaled, color, openMobile]);
    const panelStyle =
      portaled && !color && inheritedAccent
        ? ({ "--neon-accent": inheritedAccent, ...style } as React.CSSProperties)
        : style;

    const drawer = (
      <>
        <AnimatePresence>
          {drawerOpen && (
            <motion.div
              key="backdrop"
              aria-hidden
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: { duration: 0.16 } }}
              transition={{ duration: 0.24, ease: EASE }}
              onClick={() => setOpenMobile(false)}
              className="fixed inset-0 z-50 bg-black/60 md:hidden"
            />
          )}
        </AnimatePresence>
        <div
          ref={mergedRef}
          id={sidebarId}
          role={drawerOpen ? "dialog" : undefined}
          aria-modal={drawerOpen || undefined}
          aria-label={ariaLabel ?? (drawerOpen ? "Sidebar" : undefined)}
          tabIndex={drawerOpen ? -1 : undefined}
          data-state={rail ? "collapsed" : "expanded"}
          data-side={side}
          style={neonVars(color, panelStyle)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "group/sidebar flex flex-col overflow-hidden border-white/[0.08] bg-[#07080d] text-white/90 outline-none",
            "transition-[width,transform,translate,visibility] duration-[240ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
            // Below md: an off-canvas drawer over the page.
            "fixed inset-y-0 z-50 w-[min(288px,85vw)] shadow-[0_0_48px_-12px_rgb(0_0_0/0.9)]",
            side === "left" ? "left-0 border-r" : "right-0 border-l",
            openMobile
              ? "visible translate-x-0"
              : cn("invisible", side === "left" ? "-translate-x-full" : "translate-x-full"),
            // md and up: part of the layout, sticky, as tall as the viewport or the provider.
            "md:visible md:sticky md:bottom-auto md:top-0 md:z-auto md:h-[100svh] md:max-h-full md:translate-x-0 md:self-start md:shadow-none",
            side === "left" ? "md:left-auto" : "md:right-auto",
            rail ? "md:w-14" : "md:w-[248px]",
            className,
          )}
          {...props}
        >
          {children}
        </div>
      </>
    );

    return (
      <SidebarInstanceContext.Provider value={instance}>
        {portaled ? (
          <>
            <span ref={anchorRef} hidden />
            {createPortal(drawer, document.body)}
          </>
        ) : (
          drawer
        )}
        {mounted &&
          createPortal(
            <AnimatePresence>
              {visibleHint && (
                <motion.div
                  key="sidebar-hint"
                  aria-hidden
                  data-sidebar-hint=""
                  initial={{ opacity: 0, x: nudge, y: visibleHint.y, scale: reduceMotion ? 1 : 0.96 }}
                  animate={{ opacity: 1, x: 0, y: visibleHint.y, scale: 1 }}
                  exit={{
                    opacity: 0,
                    x: nudge / 2,
                    scale: reduceMotion ? 1 : 0.96,
                    transition: { duration: 0.1, ease: EASE },
                  }}
                  transition={{ duration: 0.15, ease: EASE, y: { duration: reduceMotion ? 0 : 0.2, ease: EASE } }}
                  style={
                    {
                      left: visibleHint.x,
                      transformOrigin: "0 0",
                      ...(visibleHint.neon
                        ? { "--neon": visibleHint.neon, "--neon-accent": visibleHint.neon }
                        : null),
                    } as React.CSSProperties
                  }
                  className="[--neon:var(--neon-accent,34_211_238)] pointer-events-none fixed top-0 z-[60]"
                >
                  <div
                    className={cn(
                      "flex -translate-y-1/2 items-center gap-2 whitespace-nowrap rounded-lg border border-[rgb(var(--neon)/0.4)] bg-[#0a0b11] px-2.5 py-1.5 text-xs leading-4 text-white/90",
                      "shadow-[0_0_14px_-2px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1))),0_8px_20px_-8px_rgb(0_0_0/0.8)]",
                      side === "right" && "-translate-x-full",
                    )}
                  >
                    {visibleHint.label}
                    {visibleHint.badge != null && visibleHint.badge !== false && (
                      <span className="font-medium tabular-nums text-[rgb(var(--neon))]">{visibleHint.badge}</span>
                    )}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>,
            document.body,
          )}
      </SidebarInstanceContext.Provider>
    );
  },
);
Sidebar.displayName = "Sidebar";

const SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex shrink-0 items-center gap-2 overflow-hidden p-2", className)} {...props} />
  ),
);
SidebarHeader.displayName = "SidebarHeader";

export type SidebarContentProps = Omit<
  React.HTMLAttributes<HTMLElement>,
  "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration"
>;

const SidebarContent = React.forwardRef<HTMLElement, SidebarContentProps>(
  ({ className, onScroll, "aria-label": ariaLabel, ...props }, ref) => {
    const { hideHint } = useSidebarInstance("SidebarContent");

    return (
      <motion.nav
        ref={ref}
        // Lets the active marker measure correctly inside the scrolled list.
        layoutScroll
        aria-label={ariaLabel ?? "Sidebar"}
        onScroll={(event) => {
          onScroll?.(event);
          hideHint(true);
        }}
        className={cn(
          "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden p-2",
          "[scrollbar-color:rgb(255_255_255/0.1)_transparent] [scrollbar-width:thin]",
          className,
        )}
        {...props}
      />
    );
  },
);
SidebarContent.displayName = "SidebarContent";

export interface SidebarGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  /** Small heading above the items. In the rail it turns into a short divider. */
  label?: React.ReactNode;
}

const SidebarGroup = React.forwardRef<HTMLDivElement, SidebarGroupProps>(
  ({ className, label, children, ...props }, ref) => {
    const { rail } = useSidebarInstance("SidebarGroup");
    const labelId = React.useId();
    const hasLabel = label != null && label !== false;

    return (
      <div
        ref={ref}
        role={hasLabel ? "group" : undefined}
        aria-labelledby={hasLabel ? labelId : undefined}
        className={cn("flex flex-col", className)}
        {...props}
      >
        {hasLabel && (
          <div className="relative flex h-8 shrink-0 items-center px-3">
            <span
              id={labelId}
              className={cn(
                "truncate text-xs font-medium text-white/55 transition-opacity ease-out",
                rail ? "duration-100 md:opacity-0" : "delay-75 duration-200",
              )}
            >
              {label}
            </span>
            <span
              aria-hidden
              className={cn(
                "absolute left-3 top-1/2 h-px w-4 bg-white/[0.14] opacity-0 transition-opacity duration-200 ease-out",
                rail && "md:opacity-100",
              )}
            />
          </div>
        )}
        <ul className="m-0 flex list-none flex-col gap-0.5 p-0">{children}</ul>
      </div>
    );
  },
);
SidebarGroup.displayName = "SidebarGroup";

export interface SidebarItemProps extends Omit<React.AnchorHTMLAttributes<HTMLElement>, "color"> {
  icon?: React.ReactNode;
  /** Renders a link. Without it the item is a button. */
  href?: string;
  /** Marks the current page: `aria-current="page"` and the lit tube. */
  active?: boolean;
  /** Count or short tag at the end of the row. In the rail it becomes a dot on the icon. */
  badge?: React.ReactNode;
  /** Text for the hover label in the rail. Defaults to the item's label. */
  tooltip?: React.ReactNode;
  disabled?: boolean;
  /** Merge the item onto the single child element, such as your router's Link. Its children become the label. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const SidebarItem = React.forwardRef<HTMLElement, SidebarItemProps>(
  (raw, ref) => {
    const {
      className,
      icon,
      href,
      active = false,
      badge,
      tooltip,
      disabled = false,
      asChild = false,
      children,
      onClick,
      onPointerEnter,
      onPointerLeave,
      onFocus,
      onBlur,
      ...props
    } = fromRenderProp(raw);
    const { isMobile, setOpenMobile } = useSidebarContext("SidebarItem");
    const instance = useSidebarInstance("SidebarItem");
    const { rail } = instance;

    const child =
      asChild ? resolveElement<React.HTMLAttributes<HTMLElement>>(children) : null;
    const childProps: React.HTMLAttributes<HTMLElement> = child?.props ?? {};
    const label = child ? childProps.children : children;
    const hasBadge = badge != null && badge !== false;
    const childRef = child ? getElementRef<HTMLElement>(child) : undefined;
    const mergedRef = (node: HTMLElement | null) => {
      setRef(ref, node);
      setRef(childRef, node);
    };

    const handlers = {
      onClick: (event: React.MouseEvent<HTMLElement>) => {
        if (disabled) {
          event.preventDefault();
          return;
        }
        childProps.onClick?.(event);
        onClick?.(event);
        if (!event.defaultPrevented && isMobile) setOpenMobile(false);
      },
      onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
        childProps.onPointerEnter?.(event);
        onPointerEnter?.(event);
        if (event.pointerType === "mouse" && instance.hintsEnabled) {
          instance.showHint(event.currentTarget, tooltip ?? label, hasBadge ? badge : undefined);
        }
      },
      onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {
        childProps.onPointerLeave?.(event);
        onPointerLeave?.(event);
        instance.hideHint();
      },
      onFocus: (event: React.FocusEvent<HTMLElement>) => {
        childProps.onFocus?.(event);
        onFocus?.(event);
        if (instance.hintsEnabled && isFocusVisible(event.currentTarget)) {
          instance.showHint(event.currentTarget, tooltip ?? label, hasBadge ? badge : undefined);
        }
      },
      onBlur: (event: React.FocusEvent<HTMLElement>) => {
        childProps.onBlur?.(event);
        onBlur?.(event);
        instance.hideHint();
      },
    };

    const classes = cn(
      "group/item relative flex h-9 w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden rounded-lg px-3 text-left text-sm outline-none",
      "transition-[background-color,color] duration-150 ease-out",
      "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#07080d]",
      active ? "text-white" : "text-white/65 hover:bg-white/[0.04] hover:text-white/90 active:bg-white/[0.06]",
      disabled && "pointer-events-none opacity-40",
    );

    // Labels fade out fast when collapsing and back in a beat after the rail starts to widen.
    const fade = rail ? "duration-100 md:opacity-0" : "delay-75 duration-200";

    const content = (
      <>
        <span
          className={cn(
            "relative grid size-4 shrink-0 place-items-center transition-[color,filter] duration-150 [&_svg]:size-4",
            active
              ? "text-[rgb(var(--neon))] [filter:drop-shadow(0_0_6px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))))]"
              : "text-white/50 group-hover/item:text-white/80",
          )}
        >
          {icon}
          {hasBadge && (
            <span
              aria-hidden
              className={cn(
                "absolute -right-1 -top-1 size-1.5 rounded-full bg-[rgb(var(--neon))] opacity-0 shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1)))] transition-opacity duration-200",
                rail && "md:opacity-100",
              )}
            />
          )}
        </span>
        <span className={cn("min-w-0 flex-1 truncate transition-opacity ease-out", fade)}>{label}</span>
        {hasBadge && (
          <span
            className={cn(
              "ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[11px] font-medium tabular-nums",
              "transition-[opacity,background-color,color] ease-out",
              active ? "bg-[rgb(var(--neon)/0.16)] text-[rgb(var(--neon))]" : "bg-white/[0.06] text-white/55",
              fade,
            )}
          >
            {badge}
          </span>
        )}
      </>
    );

    const shared = {
      ...props,
      ...handlers,
      "aria-current": active ? ("page" as const) : undefined,
      "data-active": active ? "" : undefined,
    };

    let element: React.ReactNode;
    if (child) {
      element = React.cloneElement(child, {
        ...shared,
        ...childProps,
        ...handlers,
        "aria-disabled": disabled || undefined,
        className: cn(classes, childProps.className, className),
        children: content,
        ref: mergedRef,
      } as React.HTMLAttributes<HTMLElement>);
    } else if (href !== undefined) {
      element = (
        <a
          ref={mergedRef}
          href={disabled ? undefined : href}
          aria-disabled={disabled || undefined}
          className={cn(classes, className)}
          {...(shared as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
        >
          {content}
        </a>
      );
    } else {
      element = (
        <button
          ref={mergedRef}
          type="button"
          disabled={disabled}
          className={cn(classes, className)}
          {...(shared as React.ButtonHTMLAttributes<HTMLButtonElement>)}
        >
          {content}
        </button>
      );
    }

    return (
      <li className="relative">
        {active && (
          <>
            <motion.span
              aria-hidden
              layoutId={instance.reduceMotion ? undefined : `${instance.layoutId}-fill`}
              initial={false}
              transition={GLIDE}
              style={{ borderRadius: 8 }}
              className="pointer-events-none absolute inset-0 bg-[rgb(var(--neon)/0.08)] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.14)]"
            />
            <motion.span
              aria-hidden
              layoutId={instance.reduceMotion ? undefined : `${instance.layoutId}-tube`}
              initial={false}
              transition={GLIDE}
              style={{ borderRadius: 3 }}
              className={cn(
                "pointer-events-none absolute inset-y-2 w-[3px] bg-[rgb(var(--neon))]",
                "shadow-[0_0_10px_rgb(var(--neon)/CALC(0.85*var(--neon-glow,1))),0_0_2px_rgb(var(--neon)/var(--neon-glow,1))]",
                instance.side === "left" ? "-left-[5px]" : "-right-[5px]",
              )}
            />
          </>
        )}
        {element}
      </li>
    );
  },
);
SidebarItem.displayName = "SidebarItem";

const SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn("flex shrink-0 flex-col gap-1 overflow-hidden border-t border-white/[0.06] p-2", className)}
      {...props}
    />
  ),
);
SidebarFooter.displayName = "SidebarFooter";

export interface SidebarTriggerProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const SidebarTrigger = React.forwardRef<HTMLButtonElement, SidebarTriggerProps>(
  ({ className, style, color, onClick, children, ...props }, ref) => {
    const { toggle, collapsed, isMobile, openMobile, sidebarId, shortcut } = useSidebarContext("SidebarTrigger");
    const key = shortcut ? shortcut.toUpperCase() : null;

    return (
      <button
        ref={ref}
        type="button"
        aria-label={children == null ? "Toggle sidebar" : undefined}
        aria-controls={sidebarId}
        aria-expanded={isMobile ? openMobile : !collapsed}
        aria-keyshortcuts={key ? `Meta+${key} Control+${key}` : undefined}
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) toggle();
        }}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-white/55 outline-none",
          "transition-[background-color,color,transform,scale] duration-150 ease-out active:scale-[0.97]",
          "hover:bg-white/[0.06] hover:text-white/90",
          "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          "[&_svg]:size-4 [&_svg]:shrink-0",
          className,
        )}
        {...props}
      >
        {children ?? <PanelLeft aria-hidden />}
      </button>
    );
  },
);
SidebarTrigger.displayName = "SidebarTrigger";

const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => {
    const { isMobile, openMobile } = useSidebarContext("SidebarInset");
    // While the drawer is open the page behind it can't be focused or clicked. Set during render, so it is
    // already gone when the drawer hands focus back.
    const inert = isMobile && openMobile;

    return (
      <div
        ref={ref}
        {...(inert ? INERT : null)}
        className={cn("relative flex min-w-0 flex-1 flex-col", className)}
        {...props}
      />
    );
  },
);
SidebarInset.displayName = "SidebarInset";

export {
  SidebarProvider,
  Sidebar,
  SidebarHeader,
  SidebarContent,
  SidebarGroup,
  SidebarItem,
  SidebarFooter,
  SidebarTrigger,
  SidebarInset,
  useSidebar,
};
```
