# App Sidebar (Lumen Halo): prompt.md (v1.3.0)

- id: `glass-sidebar` · version 1.3.0 · component · pro (All-Access)
- category: Navigation
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add @beautiful-ui-pro/glass-sidebar`
- npm dependencies: motion@^13, @web-kits/audio@^0.2
- registry dependencies: https://beautiful-ui.dev/r/beautiful-ui.json
- docs: https://beautiful-ui.dev/components/glass-sidebar
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A floating liquid-glass app sidebar in dark and light, with a gliding lens, collapsible tree, search, a collapsed rail with tooltips and an account menu.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `motion`, `@web-kits/audio`.
- Source: `components/beautiful-ui/glass-sidebar.tsx`, shared code in `lib/beautiful-ui/`.
- Exports to keep: `GlassSidebar`, and every exported type.
- CSS: none to add. Themes, tokens and keyframes are inlined by the component (a deduped `<style>` built with `lumenThemeCss` from `lib/beautiful-ui/glass.tsx`); retheme through the `--glass-*` variables, never with Tailwind colour classes inside the component.

```tsx
import { GlassSidebar } from "@/components/beautiful-ui/glass-sidebar";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `GlassSidebar` | — | The app sidebar: brand, search, sections, tree rows, account menu, collapse handle and phone drawer. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads; `sound={false}` silences one instance.
- This item: select (pitched by position) on items and children; toggleOn / toggleOff on group expand and collapse (click, → / ←) and on collapsing the sidebar (button or [); open / close for the account menu, the phone drawer and the account sheet (buttons, scrims, Escape, swipe to dismiss, outside press); open for search and ⌘K; a quiet tick on arrow keys; needs a GlassSoundProvider; sound={false} silences this instance.

## Match the original
- Read `components/beautiful-ui/glass-sidebar.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- app sidebar, dashboard navigation, workspace nav with tree, collapsible rail, account menu, mobile drawer
- The primary navigation of a dashboard, workspace or SaaS app, alongside Glass Nav on the marketing site
- Apps with a few sections of links, one level of nesting (projects, teams) and an account area
- When the sidebar should collapse to an icon rail without losing labels (hover tooltips) or keyboard access

### Not when
- Deep trees such as file explorers: children nest one level only
- Top-level marketing navigation: use Glass Nav

## Mistakes
- Don't wrap it in overflow: hidden: the collapse handle, tooltips and the account menu sit outside the sidebar's box
- Pass activeId from your router (e.g. the pathname). Items with href render plain <a> links (full page loads) unless you pass linkComponent={Link}; without href, navigate in onActiveIdChange
- With GlassCommand on the same page, let one of them own ⌘K. Palette owns it: <GlassSidebar onSearch={() => setOpen(true)} hotkey={false} /> + <GlassCommand open={open} onOpenChange={setOpen} trigger={false} />. Sidebar owns it (only opens): <GlassSidebar onSearch={() => setOpen(true)} /> + <GlassCommand open={open} onOpenChange={setOpen} hotkey={false} trigger={false} />. If both stay bound, the palette's document listener runs first and prevents the event, and the sidebar skips prevented events, so one press toggles the palette once
- Without onSearch, ⌘K is only taken while the desktop search field is on screen; in the phone layout it passes through to the page
- The default height is viewport-based (min(760px, 100dvh − 32px)); in an app shell pass height="100%" and give the parent a fixed height
- On phones it renders a fixed top bar, drawer and sheet; add top padding (about 72px) to your page. Inside a transformed parent (a preview frame) fixed layers are positioned by that parent; raise them over your own fixed UI with zIndex
- className, style, ref and HTML attributes: className/style reach the root of both layouts, but id/data-*/ref land on one <aside> only (the desktop one unless layout="mobile")
- Both layouts stay mounted and CSS picks one, so controlled state (activeId, expandedIds, query, accountMenuOpen) is shared by the desktop sidebar and the phone drawer and sheet
- Tree groups nest one level; deeper trees need a different component
- An item with children is an expand/collapse button: its own href is ignored. Put the group's page on a child (e.g. "All projects")
- The plan's "Upgrade →" only closes the menu unless you pass plan.upgradeHref or plan.onUpgrade
- Cmd/Ctrl/middle-clicking a link row opens a new tab without changing activeId or closing the drawer; plain clicks and search Enter (which clicks the first match's link) navigate
- Replace icons with 16px line icons (stroke 1.5, currentColor) so the lens and colours still work

## Usage

```tsx
import Link from "next/link";
import { usePathname } from "next/navigation";
import { GlassSidebar } from "@/components/beautiful-ui/glass-sidebar";
import { GlassCommand } from "@/components/beautiful-ui/glass-command";

// Works as is with the designed content:
<GlassSidebar />

// Your own app shell with Next.js routing and a command palette:
export function AppShell({ children, me }: { children: React.ReactNode; me: { name: string; email: string } }) {
  const pathname = usePathname();
  const [paletteOpen, setPaletteOpen] = React.useState(false);
  return (
    <div className="flex h-dvh gap-6 p-4">
      <GlassSidebar
        linkComponent={Link}
        activeId={pathname}
        height="100%"
        brand={{ name: "Kitelabs", workspace: "Production" }}
        onSearch={() => setPaletteOpen(true)}
        sections={[
          {
            id: "main",
            label: "Main",
            items: [
              { id: "/", label: "Home", href: "/", icon: <HomeIcon />, shortcut: "H" },
              { id: "/inbox", label: "Inbox", href: "/inbox", icon: <InboxIcon />, badge: 3 },
              {
                id: "projects",
                label: "Projects",
                icon: <FolderIcon />,
                children: [{ id: "/p/atlas", label: "Atlas", href: "/p/atlas", meta: "24" }],
              },
            ],
          },
        ]}
        user={{ name: me.name, email: me.email, initials: me.name.slice(0, 2).toUpperCase() }}
        plan={null}
        accountMenu={[
          { id: "settings", label: "Settings", icon: <SettingsIcon />, href: "/settings" },
          { id: "out", label: "Sign out", icon: <LogOutIcon />, onSelect: signOut, tone: "muted" },
        ]}
        labels={{ search: "Search Kitelabs" }}
      />
      {/* The sidebar owns ⌘K (it only opens); the palette neither binds it nor shows a trigger. */}
      <GlassCommand open={paletteOpen} onOpenChange={setPaletteOpen} hotkey={false} trigger={false} />
      <main className="min-w-0 flex-1 overflow-auto">{children}</main>
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `brand` | `{ name: string; workspace: string; mark?: ReactNode; onClick?: () => void }` | `{ name: "Lumen", workspace: "Studio workspace" }` | Header. The default mark is the eclipse; pass onClick to turn it into a workspace switcher button. |
| `sections` | `GlassSidebarSection[]` | `Workspace, Pinned, Teams, General` | { id, label, items: { id, label, href?, icon?, glyph?, badge?, shortcut?, children?: { id, label, href?, meta? }[] }[] }[]. Items with href render as links, otherwise buttons. shortcut shows G + key while the row is lensed. |
| `activeId` | `string` |  | Controlled active item (a top-level item or a child). |
| `defaultActiveId` | `string` | `"inbox" with the default sections` | Uncontrolled initial active item. |
| `onActiveIdChange` | `(id: string) => void` |  | Called when an item or child is chosen, by pointer, keyboard or search Enter. |
| `onNavigate` | `(id: string) => void` |  | Deprecated alias of onActiveIdChange (both fire). |
| `collapsed` | `boolean` |  | Controlled rail state (width expanded, collapsedWidth collapsed). |
| `defaultCollapsed` | `boolean` | `false` | Uncontrolled initial rail state. |
| `onCollapsedChange` | `(collapsed: boolean) => void` |  | Called by the edge handle and the [ shortcut. |
| `expandedIds` | `string[]` |  | Controlled open tree groups (item ids). |
| `defaultExpandedIds` | `string[]` | `["projects"] with the default sections` | Tree groups open on first render. |
| `onExpandedIdsChange` | `(ids: string[]) => void` |  | Called when a tree group opens or closes (desktop and phone share the state). |
| `query` | `string` |  | Controlled search text for the built-in filter (used only without onSearch). |
| `defaultQuery` | `string` | `""` | Uncontrolled initial search text. |
| `onQueryChange` | `(query: string) => void` |  | Called as the search text changes. |
| `accountMenuOpen` | `boolean` |  | Controlled account menu (desktop) / account sheet (phone). |
| `defaultAccountMenuOpen` | `boolean` | `false` | Uncontrolled initial account menu state. |
| `onAccountMenuOpenChange` | `(open: boolean) => void` |  | Called when the account menu or sheet opens or closes. |
| `drawerOpen` | `boolean` |  | Controlled phone drawer. |
| `defaultDrawerOpen` | `boolean` | `false` | Uncontrolled initial drawer state. |
| `onDrawerOpenChange` | `(open: boolean) => void` |  | Called when the phone drawer opens or closes (menu button, scrim, swipe, Escape, choosing a row). |
| `onSearch` | `() => void` |  | Opens your command palette from the search field and ⌘K / Ctrl+K. Without it, the field filters the sidebar's own items. |
| `hotkey` | `boolean` | `true` | Bind ⌘K / Ctrl+K. It is handled only when onSearch is set (calls it) or the desktop search field is on screen (expands the rail and focuses it); otherwise the key passes through untouched (phone layout, hidden sidebar). Events another handler already prevented (GlassCommand's own ⌘K) are skipped, so the key never double-toggles. false also hides the ⌘K hint. |
| `searchPlaceholder` | `string` |  | Deprecated alias of labels.search (wins when set). |
| `labels` | `Partial<GlassSidebarLabels>` | `defaultGlassSidebarLabels` | Every visible string and accessible name: search, searchButton(search, shortcut), expand, collapse, noMatches, nav, account, accountButton(name), openNavigation, closeNavigation, planTag, planUsage(unit), planRenews(date), upgrade. |
| `linkComponent` | `React.ElementType` | `"a"` | Renders every row, child, upgrade link and menu action that has an href. Pass Next's Link (or a wrapper around your router's Link) for client-side navigation. Receives href, className, style, children, onClick, aria-current, data-*. |
| `user` | `{ name: string; email: string; initials: string; avatarUrl?: string; online?: boolean }` | `Mara Quill` | The account button and the menu's identity row. |
| `plan` | `{ name; tag?; used; total; unit; renewsOn; upgradeLabel?; upgradeHref?; onUpgrade? } \| null` | `Pro plan, 8/10 seats` | Plan card in the account menu; the usage bar fills when the menu opens. Pass null to hide it. |
| `accountMenu` | `{ id; label; icon; shortcut?; href?; onSelect?; tone? }[]` | `Account settings, Billing, Keyboard shortcuts, Sign out` | Account menu actions. tone: "muted" for quiet items such as Sign out. |
| `height` | `number \| string` | `"min(760px, calc(100dvh - 32px))"` | Height of the floating sidebar; the list scrolls inside with edge fades and a custom thumb. Use "100%" inside an app shell with a fixed-height parent. |
| `width` | `number` | `272` | Expanded width in px; labels clip at width − 72. |
| `collapsedWidth` | `number` | `76` | Collapsed rail width in px. |
| `breakpoint` | `number` | `720` | Viewport width in px where layout="auto" switches to the phone layout (CSS media query, no flash). |
| `zIndex` | `number` | `30` | Base z-index of the fixed phone layer: top bar z, drawer scrim z+10, drawer z+11, account sheet scrim z+30, sheet z+31. |
| `layout` | `"auto" \| "desktop" \| "mobile"` | `"auto"` | Below breakpoint (720px) the sidebar becomes a glass top app bar, a left drawer (swipe to close) and an account bottom sheet (drag down to close). auto switches in CSS, so phones never flash the desktop layout. Add top padding (about 72px) to your page on phones for the fixed top bar. |
| `mobileTitle` | `string` |  | Title in the phone top bar. Defaults to the active item's label. |
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | full: the designed motion. subtle: calm curve, shorter, no staggers. off: instant. prefers-reduced-motion is always respected (150ms fades). "smooth" and "calm" from 1.0 still work. |
| `sound` | `boolean \| "subtle"` | `true` | true plays Lumen cues when a GlassSoundProvider enables sound; false silences this instance; "subtle" plays at 55%. Without a provider nothing plays and the engine never loads. |
| `theme` | `"system" \| "dark" \| "light"` | `"system"` | system follows a .dark / .light class or data-theme on an ancestor (next-themes, shadcn), else the OS setting. |
| `aria-label` | `string` | `"Primary" (labels.nav)` | Accessible name of the sidebar and its nav. |
| `className` | `string` |  | Added to the root of each layout (the desktop wrapper that carries the width, and the phone layer's display: contents wrapper). |
| `style` | `CSSProperties` |  | Merged last onto the same roots. Use it for CSS variables: --lg-<token> (e.g. --lg-lensBg, --lg-active, --lg-status) override the glass tokens for this sidebar, --glass-safe-top / --glass-safe-bottom / --glass-vw position the phone layer inside a frame or preview. |
| `ref` | `Ref<HTMLElement>` |  | The desktop <aside> (the drawer <aside> when layout="mobile"). |
| `...HTML attributes` | `HTMLAttributes<HTMLElement>` |  | id, data-*, aria-*, role and event handlers go on the same <aside> as ref (only one, so ids stay unique). |

Full docs: https://beautiful-ui.dev/components/glass-sidebar

## Customising
- Theme: `theme="system"` (default) follows a `.dark` / `.light` class or `data-theme` on an ancestor, else the OS. `"dark"` / `"light"` pin one.
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Phones: below 720px it switches to its phone layout in CSS. Force one with `layout="desktop"` or `layout="mobile"`.
- Colours: the family variables (`--glass-accent`, `--glass-good`, `--glass-series-1` …) on the component or any ancestor; the neutral glass is `--lg-*` in `lib/beautiful-ui/glass.tsx`.
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| ↑ / ↓, Home / End | Move through visible rows |
| → / ← | Open a tree group or enter it / close it or return to its parent |
| [ | Collapse or expand the rail (while focus is in the sidebar) |
| ⌘K / Ctrl+K | Call onSearch, or focus search when it is on screen (hotkey={false} disables) |
| ↑ / ↓, Escape in the account menu | Move between actions / close and return focus |
| Escape on phones | Close the account sheet, then the drawer, returning focus |

## Motion inventory

| Interaction | What moves |
|---|---|
| Sound | select (pitched by position) on items and children; toggleOn / toggleOff on group expand and collapse (click, → / ←) and on collapsing the sidebar (button or [); open / close for the account menu, the phone drawer and the account sheet (buttons, scrims, Escape, swipe to dismiss, outside press); open for search and ⌘K; a quiet tick on arrow keys; needs a GlassSoundProvider; sound={false} silences this instance |
| Hover or focus a row | 2D lens glides to it (600ms); icon scales 1.06, label shifts 2px |
| Collapse / expand | Width 272↔76px (550ms); labels fade and clip |
| Open a tree group | Rows expand (550ms); children stagger in 40ms apart |
| Open the account menu | Menu scales and unblurs in (500ms); seat bar fills (900ms after 150ms) |
| Phone drawer / account sheet | Slides in (600ms); follows the finger when swiped or dragged |
| motion="subtle" / "off" / reduced motion | Subtle: calm curve, shorter, no stagger. Off: instant. Reduced: 150ms fades only |

## Accessibility contract (preserve when editing)
- An <aside> landmark containing a <nav>; section groups are labelled by their headings
- ↑ / ↓, Home and End move through visible rows; → opens a tree group or enters it, ← closes it or returns to its parent
- [ toggles the rail from anywhere inside the sidebar; ⌘K / Ctrl+K focuses search or calls onSearch (hotkey={false} turns it off)
- Tree groups use aria-expanded and aria-controls; closed groups are inert, so hidden rows are never tabbable
- The account menu is a role=menu with arrow-key roving focus; Escape or an outside press closes it and returns focus
- Phones: the drawer and the account sheet (role=dialog, aria-modal) move focus in, trap it, lock page scroll, and close on Escape or the scrim, returning focus. Touch targets are 44px or larger
- The active row has aria-current=page; prefers-reduced-motion drops movement, blur, staggers and the spotlight and keeps short opacity fades

## Install

```bash
npx shadcn@latest add @beautiful-ui-pro/glass-sidebar
```

Pro item: needs the `@beautiful-ui-pro` registry in `components.json` and `BEAUTIFUL_UI_TOKEN` in `.env.local` (https://beautiful-ui.dev/account). Setup: https://beautiful-ui.dev/docs/pro

## Credits
- Sound by @web-kits/audio (https://www.npmjs.com/package/@web-kits/audio)
