# App Sidebar

> App sidebar with grouped navigation, badges and a user card; collapses to an icon rail.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/app-sidebar
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/app-sidebar.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/app-sidebar-demo.tsx"
import { BarChart3, Bell, FolderKanban, Hexagon, House, Inbox, LifeBuoy, Settings, Users } from "lucide-react"

import { AppSidebar } from "@/components/app-sidebar"

const stats = [
  { label: "Revenue", value: "$48,920", delta: "+12.4%" },
  { label: "Active users", value: "3,218", delta: "+4.1%" },
  { label: "Churn", value: "1.9%", delta: "-0.3%" },
]

const revenue = [42, 55, 48, 63, 58, 71, 66, 80, 74, 88, 83, 95]

export default function AppSidebarDemo() {
  return (
    <AppSidebar
      className="overflow-x-hidden"
      brand={
        <>
          <Hexagon className="size-5 shrink-0 fill-primary text-primary" /> Hexa
        </>
      }
      brandIcon={<Hexagon className="size-5 shrink-0 fill-primary text-primary" />}
      groups={[
        {
          items: [
            { label: "Home", href: "#", icon: <House />, active: true },
            { label: "Inbox", href: "#", icon: <Inbox />, badge: 4 },
            { label: "Notifications", href: "#", icon: <Bell /> },
          ],
        },
        {
          label: "Workspace",
          items: [
            { label: "Projects", href: "#", icon: <FolderKanban /> },
            { label: "Analytics", href: "#", icon: <BarChart3 /> },
            { label: "Members", href: "#", icon: <Users /> },
          ],
        },
        {
          label: "Support",
          items: [
            { label: "Help center", href: "#", icon: <LifeBuoy /> },
            { label: "Settings", href: "#", icon: <Settings /> },
          ],
        },
      ]}
      user={{ name: "Ada Lovelace", email: "ada@hexa.dev", initials: "AL" }}
    >
      <div className="flex h-14 items-center border-b bg-background px-6 text-sm font-medium">Home</div>
      <div className="flex flex-col gap-6 p-6">
        <div className="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-4">
          {stats.map((stat) => (
            <div key={stat.label} className="rounded-2xl border bg-background p-5">
              <p className="text-sm text-muted-foreground">{stat.label}</p>
              <p className="mt-2 text-2xl font-semibold tabular-nums">{stat.value}</p>
              <p className="mt-1 text-xs text-emerald-600 dark:text-emerald-400">{stat.delta} vs last month</p>
            </div>
          ))}
        </div>
        <div className="flex h-64 min-w-0 flex-col overflow-hidden rounded-2xl border bg-background p-5">
          <p className="text-sm font-medium">Weekly revenue</p>
          <div className="mt-4 flex flex-1 gap-1 sm:gap-3">
            {revenue.map((value, index) => (
              <div key={index} className="flex min-w-0 flex-1 flex-col items-center gap-2">
                <div className="relative w-full flex-1">
                  <div
                    className="absolute inset-x-0 bottom-0 rounded-md bg-primary/15 transition-colors hover:bg-primary/30"
                    style={{ height: `${value}%` }}
                  />
                </div>
                <span className="text-xs text-muted-foreground">W{index + 1}</span>
              </div>
            ))}
          </div>
        </div>
      </div>
    </AppSidebar>
  )
}
```

## Source

```tsx title="components/app-sidebar.tsx"
"use client"

import * as React from "react"
import { ChevronsUpDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"

import { cn } from "@/lib/utils"

export interface SidebarItem {
  label: string
  href: string
  icon: React.ReactNode
  badge?: string | number
  active?: boolean
}

export interface SidebarGroup {
  label?: string
  items: SidebarItem[]
}

export interface AppSidebarProps extends React.ComponentProps<"div"> {
  brand: React.ReactNode
  /** Small mark shown when collapsed; defaults to the brand */
  brandIcon?: React.ReactNode
  groups: SidebarGroup[]
  user?: { name: string; email: string; initials: string }
  defaultCollapsed?: boolean
  /** Starts collapsed while the viewport is narrower than this many px, until the user toggles it */
  collapseBelow?: number
  collapsible?: boolean
  /** Expanded width in px */
  width?: number
  accentColor?: string
}

function useNarrowViewport(maxWidth?: number) {
  const query = maxWidth ? `(max-width: ${maxWidth - 0.02}px)` : null
  return React.useSyncExternalStore(
    (onChange) => {
      if (!query) return () => {}
      const media = window.matchMedia(query)
      media.addEventListener("change", onChange)
      return () => media.removeEventListener("change", onChange)
    },
    () => !!query && window.matchMedia(query).matches,
    () => false
  )
}

export function AppSidebar({
  brand,
  brandIcon,
  groups,
  user,
  defaultCollapsed = false,
  collapseBelow,
  collapsible = true,
  width = 256,
  accentColor = "#f97316",
  className,
  children,
  ...props
}: AppSidebarProps) {
  // undefined follows the default (and the viewport, with collapseBelow) until the user toggles
  const [collapsed, setCollapsed] = React.useState<boolean>()
  const narrow = useNarrowViewport(collapseBelow)
  const isCollapsed = collapsible && (collapsed ?? (defaultCollapsed || narrow))
  const ToggleIcon = isCollapsed ? PanelLeftOpen : PanelLeftClose

  return (
    <div className={cn("flex min-h-svh bg-muted/30", className)} style={{ "--sb-accent": accentColor } as React.CSSProperties} {...props}>
      <aside
        data-collapsed={isCollapsed || undefined}
        style={{ width: isCollapsed ? 64 : width }}
        className="group/sidebar sticky top-0 flex h-svh shrink-0 flex-col border-r bg-background transition-[width] duration-300 ease-[cubic-bezier(.32,.72,0,1)]"
      >
        <div className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
          <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-1.5 font-semibold whitespace-nowrap">
            {isCollapsed ? (brandIcon ?? brand) : brand}
          </span>
          {collapsible && !isCollapsed && (
            <button
              type="button"
              aria-label="Collapse sidebar"
              onClick={() => setCollapsed(true)}
              className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
            >
              <ToggleIcon className="size-4" />
            </button>
          )}
        </div>

        {/* Collapsed: no scroll container, so the hover labels can overflow into the page */}
        <nav className={cn("flex min-h-0 flex-1 flex-col gap-5 p-3", !isCollapsed && "overflow-x-hidden overflow-y-auto")}>
          {groups.map((group, index) => (
            <div key={group.label ?? index} className="flex flex-col gap-0.5">
              {group.label && (
                <p
                  className={cn(
                    "px-2.5 pb-1.5 text-xs font-medium whitespace-nowrap text-muted-foreground transition-opacity",
                    isCollapsed && "opacity-0"
                  )}
                >
                  {group.label}
                </p>
              )}
              {group.items.map((item) => (
                <a
                  key={item.label}
                  href={item.href}
                  aria-current={item.active ? "page" : undefined}
                  aria-label={isCollapsed ? item.label : undefined}
                  className={cn(
                    "group/item relative flex h-9 items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0",
                    "aria-[current=page]:bg-[color-mix(in_oklab,var(--sb-accent)_12%,transparent)] aria-[current=page]:font-medium aria-[current=page]:text-foreground aria-[current=page]:[&_svg]:text-(--sb-accent)"
                  )}
                >
                  {item.icon}
                  <span className={cn("flex-1 truncate transition-opacity", isCollapsed && "opacity-0")}>{item.label}</span>
                  {item.badge !== undefined && (
                    <span
                      className={cn(
                        "rounded-full bg-(--sb-accent) px-1.5 text-[11px] leading-4.5 font-medium text-neutral-950 tabular-nums",
                        isCollapsed && "absolute top-1 right-1 size-2 p-0 text-[0px]"
                      )}
                    >
                      {item.badge}
                    </span>
                  )}
                  {isCollapsed && (
                    <span className="pointer-events-none absolute left-full z-50 ml-3 hidden rounded-md bg-foreground px-2 py-1 text-xs whitespace-nowrap text-background shadow-md group-hover/item:block group-focus-visible/item:block">
                      {item.label}
                    </span>
                  )}
                </a>
              ))}
            </div>
          ))}
        </nav>

        {collapsible && isCollapsed && (
          <button
            type="button"
            aria-label="Expand sidebar"
            onClick={() => setCollapsed(false)}
            className="mx-3 mb-2 flex size-10 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            <ToggleIcon className="size-4" />
          </button>
        )}

        {user && (
          <div className="shrink-0 border-t p-3">
            <button type="button" className="flex w-full items-center gap-3 rounded-lg p-1.5 text-left hover:bg-accent">
              <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-(--sb-accent) text-xs font-semibold text-neutral-950">
                {user.initials}
              </span>
              <span className={cn("min-w-0 flex-1 transition-opacity", isCollapsed && "opacity-0")}>
                <span className="block truncate text-sm font-medium">{user.name}</span>
                <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
              </span>
              <ChevronsUpDown className={cn("size-4 shrink-0 text-muted-foreground", isCollapsed && "hidden")} />
            </button>
          </div>
        )}
      </aside>
      <main className="min-w-0 flex-1">{children}</main>
    </div>
  )
}
```
