Sidebar

A collapsible app sidebar that renders as a sheet on small screens.

Platform
"use client"

import type { ElementType } from "react"
import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  collapsibleStyles,
} from "@/components/ui/collapsible"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Separator } from "@/components/ui/separator"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuAction,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  sidebarStyles,
  useSidebar,
} from "@/components/ui/sidebar"
import { HStack, VStack } from "@/components/ui/stack"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { color, leading, radius, space, text } from "@/theme/tokens.stylex"
import {
  AudioWaveform,
  BadgeCheck,
  Bell,
  BookOpen,
  Bot,
  ChevronRight,
  ChevronsUpDown,
  Command,
  CreditCard,
  Folder,
  Forward,
  Frame,
  GalleryVerticalEnd,
  LogOut,
  Map,
  MoreHorizontal,
  PieChart,
  Plus,
  Settings2,
  Sparkles,
  SquareTerminal,
  Trash2,
} from "lucide-react"

const sidebarPrimary = "light-dark(oklch(0.205 0 0), oklch(0.922 0 0))"
const sidebarPrimaryForeground = "light-dark(oklch(0.985 0 0), oklch(0.205 0 0))"

const styles = stylex.create({
  logo: {
    display: "flex",
    aspectRatio: "1",
    width: space.s8,
    height: space.s8,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.lg,
    backgroundColor: sidebarPrimary,
    color: sidebarPrimaryForeground,
  },
  lines: { display: "grid", flexGrow: 1, minWidth: 0, textAlign: "left" },
  title: { fontWeight: 500 },
  detail: { fontSize: text.xs, lineHeight: leading.xs },
  trailing: { marginInlineStart: "auto" },
  menu: { minWidth: "14rem" },
  teamLogo: {
    display: "flex",
    width: space.s6,
    height: space.s6,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    "--carbon-icon-size": space.s3_5,
  },
  muted: { color: color.mutedForeground },
  header: { height: space.s16, flexShrink: 0 },
  headerRule: { height: space.s4 },
  wideOnly: { display: { default: "none", [breakpoints.md]: "inline-flex" } },
  grid: {
    display: "grid",
    gap: space.s4,
    gridTemplateColumns: { default: "1fr", [breakpoints.md]: "repeat(3, 1fr)" },
  },
  tile: {
    aspectRatio: "16 / 9",
    borderRadius: radius.xl,
    backgroundColor: `color-mix(in oklab, ${color.muted} 50%, transparent)`,
  },
  canvas: {
    flexGrow: 1,
    borderRadius: radius.xl,
    backgroundColor: `color-mix(in oklab, ${color.muted} 50%, transparent)`,
  },
})

const data = {
  user: { name: "shadcn", email: "m@example.com", avatar: "https://github.com/shadcn.png" },
  teams: [
    { name: "Acme Inc", logo: GalleryVerticalEnd, plan: "Enterprise" },
    { name: "Acme Corp.", logo: AudioWaveform, plan: "Startup" },
    { name: "Evil Corp.", logo: Command, plan: "Free" },
  ],
  navMain: [
    {
      title: "Playground",
      url: "#",
      icon: SquareTerminal,
      isActive: true,
      items: [
        { title: "History", url: "#", isActive: true },
        { title: "Starred", url: "#" },
        { title: "Settings", url: "#" },
      ],
    },
    {
      title: "Models",
      url: "#",
      icon: Bot,
      items: [
        { title: "Genesis", url: "#" },
        { title: "Explorer", url: "#" },
        { title: "Quantum", url: "#" },
      ],
    },
    {
      title: "Documentation",
      url: "#",
      icon: BookOpen,
      items: [
        { title: "Introduction", url: "#" },
        { title: "Get Started", url: "#" },
        { title: "Tutorials", url: "#" },
        { title: "Changelog", url: "#" },
      ],
    },
    {
      title: "Settings",
      url: "#",
      icon: Settings2,
      items: [
        { title: "General", url: "#" },
        { title: "Team", url: "#" },
        { title: "Billing", url: "#" },
        { title: "Limits", url: "#" },
      ],
    },
  ],
  projects: [
    { name: "Design Engineering", url: "#", icon: Frame },
    { name: "Sales & Marketing", url: "#", icon: PieChart },
    { name: "Travel", url: "#", icon: Map },
  ],
}

function TeamSwitcher({ teams }: { teams: { name: string; logo: ElementType; plan: string }[] }) {
  const { isMobile } = useSidebar()
  const [activeTeam, setActiveTeam] = useState(teams[0])

  if (!activeTeam) return null

  return (
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger render={<SidebarMenuButton size="lg" />}>
            <div {...stylex.props(styles.logo)}>
              <activeTeam.logo />
            </div>
            <div {...stylex.props(styles.lines)}>
              <span {...stylex.props(sidebarStyles.truncate, styles.title)}>{activeTeam.name}</span>
              <span {...stylex.props(sidebarStyles.truncate, styles.detail)}>
                {activeTeam.plan}
              </span>
            </div>
            <ChevronsUpDown {...stylex.props(styles.trailing)} />
          </DropdownMenuTrigger>
          <DropdownMenuContent
            xstyle={styles.menu}
            align="start"
            side={isMobile ? "bottom" : "right"}
            sideOffset={4}
          >
            <DropdownMenuGroup>
              <DropdownMenuLabel>Teams</DropdownMenuLabel>
              {teams.map((team, index) => (
                <DropdownMenuItem key={team.name} onClick={() => setActiveTeam(team)}>
                  <div {...stylex.props(styles.teamLogo)}>
                    <team.logo />
                  </div>
                  {team.name}
                  <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
                </DropdownMenuItem>
              ))}
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <div {...stylex.props(styles.teamLogo)}>
                  <Plus />
                </div>
                <span {...stylex.props(styles.title, styles.muted)}>Add team</span>
              </DropdownMenuItem>
            </DropdownMenuGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  )
}

function NavMain({
  items,
}: {
  items: {
    title: string
    url: string
    icon?: ElementType
    isActive?: boolean
    items?: { title: string; url: string; isActive?: boolean }[]
  }[]
}) {
  return (
    <SidebarGroup>
      <SidebarGroupLabel>Platform</SidebarGroupLabel>
      <SidebarMenu>
        {items.map((item) => (
          <Collapsible key={item.title} defaultOpen={item.isActive}>
            <SidebarMenuItem>
              <CollapsibleTrigger render={<SidebarMenuButton tooltip={item.title} />}>
                {item.icon && <item.icon />}
                <span {...stylex.props(sidebarStyles.truncate)}>{item.title}</span>
                <ChevronRight {...stylex.props(collapsibleStyles.chevronInline, styles.trailing)} />
              </CollapsibleTrigger>
              <CollapsibleContent>
                <SidebarMenuSub>
                  {item.items?.map((subItem) => (
                    <SidebarMenuSubItem key={subItem.title}>
                      <SidebarMenuSubButton
                        render={<a href={subItem.url} />}
                        isActive={subItem.isActive}
                      >
                        <span {...stylex.props(sidebarStyles.truncate)}>{subItem.title}</span>
                      </SidebarMenuSubButton>
                    </SidebarMenuSubItem>
                  ))}
                </SidebarMenuSub>
              </CollapsibleContent>
            </SidebarMenuItem>
          </Collapsible>
        ))}
      </SidebarMenu>
    </SidebarGroup>
  )
}

function NavProjects({
  projects,
}: {
  projects: { name: string; url: string; icon: ElementType }[]
}) {
  const { isMobile } = useSidebar()

  return (
    <SidebarGroup>
      <SidebarGroupLabel>Projects</SidebarGroupLabel>
      <SidebarMenu>
        {projects.map((item) => (
          <SidebarMenuItem key={item.name}>
            <SidebarMenuButton render={<a href={item.url} />}>
              <item.icon />
              <span {...stylex.props(sidebarStyles.truncate)}>{item.name}</span>
            </SidebarMenuButton>
            <DropdownMenu>
              <DropdownMenuTrigger render={<SidebarMenuAction showOnHover aria-label="More" />}>
                <MoreHorizontal />
              </DropdownMenuTrigger>
              <DropdownMenuContent
                side={isMobile ? "bottom" : "right"}
                align={isMobile ? "end" : "start"}
              >
                <DropdownMenuItem>
                  <Folder {...stylex.props(styles.muted)} />
                  <span>View Project</span>
                </DropdownMenuItem>
                <DropdownMenuItem>
                  <Forward {...stylex.props(styles.muted)} />
                  <span>Share Project</span>
                </DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem>
                  <Trash2 {...stylex.props(styles.muted)} />
                  <span>Delete Project</span>
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </SidebarMenuItem>
        ))}
        <SidebarMenuItem>
          <SidebarMenuButton xstyle={styles.muted}>
            <MoreHorizontal />
            <span>More</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarGroup>
  )
}

function NavUser({ user }: { user: { name: string; email: string; avatar: string } }) {
  const { isMobile } = useSidebar()

  return (
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger render={<SidebarMenuButton size="lg" />}>
            <Avatar>
              <AvatarImage src={user.avatar} alt={user.name} />
              <AvatarFallback>CN</AvatarFallback>
            </Avatar>
            <div {...stylex.props(styles.lines)}>
              <span {...stylex.props(sidebarStyles.truncate, styles.title)}>{user.name}</span>
              <span {...stylex.props(sidebarStyles.truncate, styles.detail)}>{user.email}</span>
            </div>
            <ChevronsUpDown {...stylex.props(styles.trailing)} />
          </DropdownMenuTrigger>
          <DropdownMenuContent
            xstyle={styles.menu}
            side={isMobile ? "bottom" : "right"}
            align="end"
            sideOffset={4}
          >
            <DropdownMenuGroup>
              <DropdownMenuLabel>
                <HStack gap={2} align="center">
                  <Avatar>
                    <AvatarImage src={user.avatar} alt={user.name} />
                    <AvatarFallback>CN</AvatarFallback>
                  </Avatar>
                  <div {...stylex.props(styles.lines)}>
                    <span {...stylex.props(sidebarStyles.truncate, styles.title)}>{user.name}</span>
                    <span {...stylex.props(sidebarStyles.truncate, styles.detail)}>
                      {user.email}
                    </span>
                  </div>
                </HStack>
              </DropdownMenuLabel>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <Sparkles />
                Upgrade to Pro
              </DropdownMenuItem>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <BadgeCheck />
                Account
              </DropdownMenuItem>
              <DropdownMenuItem>
                <CreditCard />
                Billing
              </DropdownMenuItem>
              <DropdownMenuItem>
                <Bell />
                Notifications
              </DropdownMenuItem>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <LogOut />
                Log out
              </DropdownMenuItem>
            </DropdownMenuGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  )
}

export default function SidebarDemo() {
  return (
    <SidebarProvider>
      <Sidebar collapsible="icon">
        <SidebarHeader>
          <TeamSwitcher teams={data.teams} />
        </SidebarHeader>
        <SidebarContent>
          <NavMain items={data.navMain} />
          <NavProjects projects={data.projects} />
        </SidebarContent>
        <SidebarFooter>
          <NavUser user={data.user} />
        </SidebarFooter>
        <SidebarRail />
      </Sidebar>
      <SidebarInset>
        <HStack render={<header />} gap={2} align="center" paddingInline={4} xstyle={styles.header}>
          <SidebarTrigger />
          <Separator orientation="vertical" xstyle={styles.headerRule} />
          <Breadcrumb>
            <BreadcrumbList>
              <BreadcrumbItem xstyle={styles.wideOnly}>
                <BreadcrumbLink href="#">Build Your Application</BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator xstyle={styles.wideOnly} />
              <BreadcrumbItem>
                <BreadcrumbPage>Data Fetching</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
        </HStack>
        <VStack gap={4} grow paddingInline={4} paddingBlockEnd={4}>
          <div {...stylex.props(styles.grid)}>
            <div {...stylex.props(styles.tile)} />
            <div {...stylex.props(styles.tile)} />
            <div {...stylex.props(styles.tile)} />
          </div>
          <div {...stylex.props(styles.canvas)} />
        </VStack>
      </SidebarInset>
    </SidebarProvider>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation. The sidebar also needs interop.css for the open-menu states and the no-scrollbar hook, the sidebarMenuItem marker from markers.stylex.ts on the same page, and the other components it renders: Button, Input, Separator, Sheet, Skeleton, Stack and Tooltip.

Copy and paste the following code into your project.

components/ui/sidebar.tsx
"use client"

import type { ComponentProps } from "react"
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from "react"
import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { PanelLeft } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { VStack } from "@/components/ui/stack"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { sidebarMenuItem } from "@/theme/markers.stylex"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const sidebarForeground = "light-dark(oklch(0.145 0 0), oklch(0.985 0 0))"
const sidebarAccent = "light-dark(oklch(0.95 0 0), oklch(0.269 0 0))"
const sidebarAccentForeground = "light-dark(oklch(0.205 0 0), oklch(0.985 0 0))"
const sidebarBorder = "light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))"

const SIDEBAR_COOKIE_NAME = "carbon-sidebar"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_PEEK_CLOSE_DELAY = 250

const MOBILE_QUERY = "(width < 48rem)"

type SidebarSide = "left" | "right"
type SidebarVariant = "sidebar" | "floating" | "inset"
type SidebarCollapsible = "offcanvas" | "icon" | "none"
type SidebarState = "expanded" | "collapsed"

type SidebarContextValue = {
  state: SidebarState
  open: boolean
  setOpen: (open: boolean) => void
  openMobile: boolean
  setOpenMobile: (open: boolean) => void
  isMobile: boolean
  toggleSidebar: () => void
  variant: SidebarVariant
  peeking: boolean
  setPeeking: (peeking: boolean) => void
  instant: boolean
}

const SidebarContext = createContext<SidebarContextValue | null>(null)

const SidebarPanelContext = createContext<{
  side: SidebarSide
  collapsible: SidebarCollapsible
  iconMode: boolean
}>({ side: "left", collapsible: "offcanvas", iconMode: false })

const SidebarMenuItemContext = createContext<{
  size: SidebarMenuButtonSize
  setSize: (size: SidebarMenuButtonSize) => void
  hasAction: boolean
  setHasAction: (hasAction: boolean) => void
} | null>(null)

const styles = stylex.create({
  wrapper: {
    display: "flex",
    minHeight: "100svh",
    width: "100%",
    "--carbon-sidebar-width": "16rem",
    "--carbon-sidebar-width-icon": "3rem",
  },
  wrapperInset: { backgroundColor: color.tray },
  root: {
    display: { default: "none", [breakpoints.md]: "block" },
    color: sidebarForeground,
  },
  gap: {
    position: "relative",
    width: "var(--carbon-sidebar-width)",
    backgroundColor: color.transparent,
    transitionProperty: "width",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  gapOffcanvas: { width: 0 },
  instant: { transitionDuration: "0s" },
  gapIcon: { width: "var(--carbon-sidebar-width-icon)" },
  gapIconPadded: { width: `calc(var(--carbon-sidebar-width-icon) + ${space.s4})` },
  container: {
    position: "fixed",
    insetBlock: 0,
    zIndex: layer.overlay,
    display: "flex",
    width: "var(--carbon-sidebar-width)",
    transitionProperty: "left, right, width",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  containerLeft: { left: 0 },
  containerRight: { right: 0 },
  containerLeftOffcanvas: { left: "calc(-1 * var(--carbon-sidebar-width))" },
  containerRightOffcanvas: { right: "calc(-1 * var(--carbon-sidebar-width))" },
  containerPeekLeft: { boxShadow: shadow.peekLeft },
  containerPeekRight: { boxShadow: shadow.peekRight },
  containerIcon: { width: "var(--carbon-sidebar-width-icon)" },
  containerIconPadded: { width: `calc(var(--carbon-sidebar-width-icon) + ${space.s4} + 2px)` },
  containerPadded: { padding: space.s2 },
  containerBorderRight: {
    borderRightWidth: 1,
    borderRightStyle: "solid",
    borderRightColor: sidebarBorder,
  },
  containerBorderLeft: {
    borderLeftWidth: 1,
    borderLeftStyle: "solid",
    borderLeftColor: sidebarBorder,
  },
  inner: {
    display: "flex",
    width: "100%",
    height: "100%",
    flexDirection: "column",
    backgroundColor: color.sidebar,
  },
  innerFloating: {
    borderRadius: radius.lg,
    boxShadow: shadow.raised,
  },
  innerInset: { backgroundColor: color.transparent },
  none: {
    display: "flex",
    height: "100%",
    width: "var(--carbon-sidebar-width)",
    flexDirection: "column",
    backgroundColor: color.sidebar,
    color: sidebarForeground,
  },
  sheet: {
    width: "18rem",
    padding: 0,
    gap: 0,
    backgroundColor: color.sidebar,
    color: sidebarForeground,
    "--carbon-sidebar-width": "18rem",
  },
  sheetBody: { display: "flex", height: "100%", width: "100%", flexDirection: "column" },
  rail: {
    position: "absolute",
    insetBlock: 0,
    zIndex: layer.sticky,
    display: { default: "none", [breakpoints.sm]: "flex" },
    width: space.s4,
    transform: "translateX(-50%)",
    transitionProperty: "background-color, left, right, transform",
    transitionDuration: {
      default: `${duration.color}, ${duration.panel}, ${duration.panel}, ${duration.panel}`,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: `${ease.standard}, ${ease.spring}, ${ease.spring}, ${ease.spring}`,
    "::after": {
      content: "''",
      position: "absolute",
      insetBlock: 0,
      left: "50%",
      width: 2,
      backgroundColor: { default: null, ":hover": sidebarBorder },
    },
  },
  railLeft: { right: `calc(-1 * ${space.s4})`, cursor: "w-resize" },
  railRight: { left: 0, cursor: "e-resize" },
  railLeftCollapsed: { cursor: "e-resize" },
  railRightCollapsed: { cursor: "w-resize" },
  railOffcanvas: {
    transform: "translateX(0)",
    backgroundColor: { default: null, ":hover": color.sidebar },
    "::after": { left: "100%" },
  },
  railLeftOffcanvas: { right: `calc(-1 * ${space.s2})` },
  railRightOffcanvas: { left: `calc(-1 * ${space.s2})` },
  inset: {
    position: "relative",
    display: "flex",
    width: "100%",
    flexGrow: 1,
    flexDirection: "column",
    backgroundColor: color.background,
  },
  insetVariant: {
    margin: { default: null, [breakpoints.md]: space.s2 },
    marginInlineStart: { default: null, [breakpoints.md]: 0 },
    borderRadius: { default: null, [breakpoints.md]: radius.xl },
    backgroundColor: { default: color.background, [breakpoints.md]: color.card },
    boxShadow: { default: null, [breakpoints.md]: shadow.raised },
  },
  insetVariantCollapsed: { marginInlineStart: { default: null, [breakpoints.md]: space.s2 } },
  input: { backgroundColor: { default: color.background, ":disabled": color.inputDisabledBg } },
  separator: { marginInline: space.s2, width: "auto", backgroundColor: sidebarBorder },
  contentIcon: { overflow: "hidden" },
  group: { position: "relative", width: "100%" },
  groupLabel: {
    display: "flex",
    height: space.s8,
    flexShrink: 0,
    alignItems: "center",
    borderRadius: radius.md,
    paddingInline: space.s2,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    color: `color-mix(in oklab, ${sidebarForeground} 70%, transparent)`,
    transitionProperty: "margin, opacity",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
    "--carbon-icon-size": space.s4,
  },
  groupLabelIcon: { marginBlockStart: `calc(-1 * ${space.s8})`, opacity: 0 },
  groupAction: {
    position: "absolute",
    top: space.s3_5,
    right: space.s3,
    display: "flex",
    aspectRatio: "1",
    width: space.s5,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.md,
    padding: 0,
    color: { default: sidebarForeground, ":hover": sidebarAccentForeground },
    backgroundColor: { default: null, ":hover": color.itemHighlightBg },
    transitionProperty: "color, background-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s4,
    "::after": {
      content: "''",
      position: "absolute",
      inset: `calc(-1 * ${space.s2})`,
      display: { default: "block", [breakpoints.md]: "none" },
    },
  },
  groupContent: { width: "100%", fontSize: text.sm, lineHeight: leading.sm },
  menu: { width: "100%" },
  menuItem: { position: "relative" },
  menuButton: {
    display: "flex",
    width: "100%",
    alignItems: "center",
    gap: space.s2,
    overflow: "hidden",
    borderRadius: radius.md,
    padding: space.s2,
    textAlign: "left",
    fontSize: text.sm,
    lineHeight: leading.sm,
    backgroundColor: {
      default: null,
      ":hover": color.itemHighlightBg,
      ":active": color.itemHighlightBg,
    },
    color: {
      default: null,
      ":hover": sidebarAccentForeground,
      ":active": sidebarAccentForeground,
    },
    transitionProperty: "width, height, padding, color, background-color, box-shadow",
    transitionDuration: {
      default: duration.color,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
    pointerEvents: { default: null, ":disabled": "none" },
    opacity: { default: 1, ":disabled": 0.5 },
    "--carbon-icon-size": space.s4,
    "--carbon-menu-open-bg": color.itemHighlightBg,
    "--carbon-menu-open-fg": sidebarAccentForeground,
  },
  rowActive: {
    backgroundColor: sidebarAccent,
    color: sidebarAccentForeground,
  },
  rowActiveLabel: {
    WebkitTextStrokeWidth: "0.3px",
    WebkitTextStrokeColor: "currentColor",
  },
  menuButtonWithAction: { paddingInlineEnd: space.s8 },
  menuButtonIcon: { width: space.s8, height: space.s8, padding: space.s2 },
  menuButtonIconLg: { padding: 0 },
  menuAction: {
    position: "absolute",
    right: space.s1,
    display: "flex",
    aspectRatio: "1",
    width: space.s5,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.md,
    padding: 0,
    color: {
      default: sidebarForeground,
      ":hover": sidebarAccentForeground,
      [stylex.when.ancestor(":hover", sidebarMenuItem)]: sidebarAccentForeground,
    },
    backgroundColor: { default: null, ":hover": color.itemHighlightBg },
    transitionProperty: "color, background-color, box-shadow, opacity",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s4,
    "::after": {
      content: "''",
      position: "absolute",
      inset: `calc(-1 * ${space.s2})`,
      display: { default: "block", [breakpoints.md]: "none" },
    },
  },
  menuActionShowOnHover: {
    opacity: {
      default: 1,
      [breakpoints.md]: {
        default: 0,
        [stylex.when.ancestor(":hover", sidebarMenuItem)]: 1,
        [stylex.when.ancestor(":focus-within", sidebarMenuItem)]: 1,
      },
    },
  },
  menuBadge: {
    pointerEvents: "none",
    position: "absolute",
    right: space.s1,
    display: "flex",
    height: space.s5,
    minWidth: space.s5,
    alignItems: "center",
    justifyContent: "center",
    userSelect: "none",
    borderRadius: radius.md,
    paddingInline: space.s1,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    fontVariantNumeric: "tabular-nums",
    color: {
      default: sidebarForeground,
      [stylex.when.ancestor(":hover", sidebarMenuItem)]: sidebarAccentForeground,
    },
  },
  menuSkeleton: {
    display: "flex",
    height: space.s8,
    alignItems: "center",
    gap: space.s2,
    borderRadius: radius.md,
    paddingInline: space.s2,
  },
  menuSkeletonIcon: { width: space.s4, height: space.s4, borderRadius: radius.md },
  menuSkeletonText: { height: space.s4, flexGrow: 1 },
  menuSub: {
    marginInline: space.s3_5,
    display: "flex",
    minWidth: 0,
    transform: "translateX(1px)",
    flexDirection: "column",
    gap: space.s1,
    borderLeftWidth: 1,
    borderLeftStyle: "solid",
    borderLeftColor: sidebarBorder,
    paddingInline: space.s2_5,
    paddingBlock: space.s0_5,
  },
  menuSubItem: { position: "relative" },
  menuSubButton: {
    display: "flex",
    height: space.s7,
    minWidth: 0,
    transform: "translateX(-1px)",
    alignItems: "center",
    gap: space.s2,
    overflow: "hidden",
    borderRadius: radius.md,
    paddingInline: space.s2,
    color: {
      default: sidebarForeground,
      ":hover": sidebarAccentForeground,
      ":active": sidebarAccentForeground,
    },
    backgroundColor: {
      default: null,
      ":hover": color.itemHighlightBg,
      ":active": color.itemHighlightBg,
    },
    transitionProperty: "color, background-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    pointerEvents: { default: null, ":disabled": "none" },
    opacity: { default: 1, ":disabled": 0.5 },
    "--carbon-icon-size": space.s4,
  },
  truncate: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
  hidden: { display: "none" },
})

const menuButtonVariants = stylex.create({
  default: {},
  outline: {
    backgroundColor: {
      default: color.background,
      ":hover": color.itemHighlightBg,
      ":active": color.itemHighlightBg,
    },
    boxShadow: {
      default: `0 0 0 1px ${sidebarBorder}`,
      ":hover": `0 0 0 1px ${sidebarAccent}`,
      ":focus-visible": shadow.rowRing,
    },
  },
})

const menuButtonSizes = stylex.create({
  default: { height: space.s8, fontSize: text.sm, lineHeight: leading.sm },
  sm: { height: space.s7, fontSize: text.xs, lineHeight: leading.xs },
  lg: { height: space.s12, fontSize: text.sm, lineHeight: leading.sm },
})

const menuActionTop = stylex.create({
  default: { top: space.s1_5 },
  sm: { top: space.s1 },
  lg: { top: space.s2_5 },
})

const menuSubButtonSizes = stylex.create({
  sm: { fontSize: text.xs, lineHeight: leading.xs },
  md: { fontSize: text.sm, lineHeight: leading.sm },
})

const sidebarStyles = {
  menuButton: [styles.menuButton, states.rowFocusRing],
  menuButtonVariants,
  menuButtonSizes,
  menuSubButton: [styles.menuSubButton, states.rowFocusRing],
  truncate: styles.truncate,
}

type SidebarMenuButtonVariant = keyof typeof menuButtonVariants
type SidebarMenuButtonSize = keyof typeof menuButtonSizes
type SidebarMenuSubButtonSize = keyof typeof menuSubButtonSizes

type Overrides = { xstyle?: StyleXStyles; className?: string }

function subscribeToViewport(onChange: () => void) {
  const list = window.matchMedia(MOBILE_QUERY)
  list.addEventListener("change", onChange)
  return () => list.removeEventListener("change", onChange)
}

function useIsMobile() {
  return useSyncExternalStore(
    subscribeToViewport,
    () => window.matchMedia(MOBILE_QUERY).matches,
    () => false,
  )
}

function useSidebar() {
  const context = use(SidebarContext)
  if (!context) {
    throw new Error("useSidebar must be used within a SidebarProvider.")
  }
  return context
}

type SidebarProviderProps = useRender.ComponentProps<"div"> &
  Overrides & {
    defaultOpen?: boolean
    open?: boolean
    onOpenChange?: (open: boolean) => void
    variant?: SidebarVariant
  }

function SidebarProvider({
  render,
  ref,
  style,
  defaultOpen = true,
  open: openProp,
  onOpenChange: setOpenProp,
  variant = "sidebar",
  xstyle,
  className,
  children,
  ...props
}: SidebarProviderProps) {
  const isMobile = useIsMobile()
  const [openMobile, setOpenMobile] = useState(false)

  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen

  const [instant, setInstant] = useState(false)
  const restoreFrom = useRef({ controlled: openProp !== undefined, open: uncontrolledOpen })
  useLayoutEffect(() => {
    if (restoreFrom.current.controlled) return
    const saved = new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=(true|false)`).exec(
      document.cookie,
    )?.[1]
    if (saved === undefined || (saved === "true") === restoreFrom.current.open) return
    setInstant(true)
    setUncontrolledOpen(saved === "true")
    let frame = requestAnimationFrame(() => {
      frame = requestAnimationFrame(() => setInstant(false))
    })
    return () => cancelAnimationFrame(frame)
  }, [])
  const setOpen = useCallback(
    (value: boolean | ((open: boolean) => boolean)) => {
      const next = typeof value === "function" ? value(open) : value
      if (setOpenProp) {
        setOpenProp(next)
      } else {
        setUncontrolledOpen(next)
      }
      document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
    },
    [setOpenProp, open],
  )

  const [peeking, setPeekingNow] = useState(false)
  const peekTimer = useRef<number | undefined>(undefined)
  const setPeeking = useCallback((next: boolean) => {
    window.clearTimeout(peekTimer.current)
    if (next) setPeekingNow(true)
    else peekTimer.current = window.setTimeout(() => setPeekingNow(false), SIDEBAR_PEEK_CLOSE_DELAY)
  }, [])
  useEffect(() => {
    if (open) {
      window.clearTimeout(peekTimer.current)
      setPeekingNow(false)
    }
  }, [open])
  useEffect(() => () => window.clearTimeout(peekTimer.current), [])

  const toggleSidebar = useCallback(() => {
    if (isMobile) {
      setOpenMobile((current) => !current)
    } else {
      setOpen((current) => !current)
    }
  }, [isMobile, setOpen])

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
        event.preventDefault()
        toggleSidebar()
      } else if (event.key === "Escape") {
        window.clearTimeout(peekTimer.current)
        setPeekingNow(false)
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [toggleSidebar])

  const state: SidebarState = open ? "expanded" : "collapsed"

  const contextValue = useMemo<SidebarContextValue>(
    () => ({
      state,
      open,
      setOpen,
      isMobile,
      openMobile,
      setOpenMobile,
      toggleSidebar,
      variant,
      peeking,
      setPeeking,
      instant,
    }),
    [
      state,
      open,
      setOpen,
      isMobile,
      openMobile,
      toggleSidebar,
      variant,
      peeking,
      setPeeking,
      instant,
    ],
  )

  const sx = stylex.props(styles.wrapper, variant === "inset" && styles.wrapperInset, xstyle)

  return (
    <SidebarContext value={contextValue}>
      {useRender({
        defaultTagName: "div",
        render,
        ref,
        props: {
          "data-slot": "sidebar-wrapper",
          "data-variant": variant,
          ...sx,
          ...props,
          className: clsx(sx.className, className),
          style: { ...sx.style, ...style },
          children,
        },
      })}
    </SidebarContext>
  )
}

type SidebarProps = useRender.ComponentProps<"div"> &
  Overrides & {
    side?: SidebarSide
    collapsible?: SidebarCollapsible
  }

function Sidebar({
  style,
  side = "left",
  collapsible = "offcanvas",
  xstyle,
  className,
  children,
  onPointerEnter,
  onPointerLeave,
  ...props
}: SidebarProps) {
  const { isMobile, state, openMobile, setOpenMobile, variant, peeking, setPeeking, instant } =
    useSidebar()

  if (collapsible === "none") {
    const sx = stylex.props(styles.none, xstyle)
    return (
      <SidebarPanelContext value={{ side, collapsible, iconMode: false }}>
        <SidebarPanel
          data-slot="sidebar"
          data-side={side}
          data-variant={variant}
          {...sx}
          {...props}
          className={clsx(sx.className, className)}
          style={{ ...sx.style, ...style }}
        >
          {children}
        </SidebarPanel>
      </SidebarPanelContext>
    )
  }

  if (isMobile) {
    return (
      <SidebarPanelContext value={{ side, collapsible, iconMode: false }}>
        <Sheet open={openMobile} onOpenChange={setOpenMobile}>
          <SheetContent
            data-sidebar="sidebar"
            data-mobile=""
            data-side={side}
            side={side}
            showCloseButton={false}
            xstyle={[styles.sheet, xstyle]}
            style={style}
            className={className}
            {...props}
          >
            <SheetHeader xstyle={states.srOnly}>
              <SheetTitle>Sidebar</SheetTitle>
              <SheetDescription>Displays the mobile sidebar.</SheetDescription>
            </SheetHeader>
            <div {...stylex.props(styles.sheetBody)}>{children}</div>
          </SheetContent>
        </Sheet>
      </SidebarPanelContext>
    )
  }

  const collapsed = state === "collapsed"
  const offcanvas = collapsed && collapsible === "offcanvas"
  const peek = offcanvas && peeking
  const icon = collapsed && collapsible === "icon"
  const padded = variant === "floating" || variant === "inset"
  const sx = stylex.props(
    styles.container,
    side === "left" ? styles.containerLeft : styles.containerRight,
    offcanvas &&
      !peek &&
      (side === "left" ? styles.containerLeftOffcanvas : styles.containerRightOffcanvas),
    peek && (side === "left" ? styles.containerPeekLeft : styles.containerPeekRight),
    instant && styles.instant,
    padded && styles.containerPadded,
    icon && (padded ? styles.containerIconPadded : styles.containerIcon),
    !padded && (side === "left" ? styles.containerBorderRight : styles.containerBorderLeft),
    xstyle,
  )

  return (
    <SidebarPanelContext value={{ side, collapsible, iconMode: icon }}>
      <div
        data-slot="sidebar"
        data-state={state}
        data-collapsible={collapsed ? collapsible : ""}
        data-peek={peek ? "" : undefined}
        data-variant={variant}
        data-side={side}
        {...stylex.props(styles.root)}
      >
        <div
          data-slot="sidebar-gap"
          {...stylex.props(
            styles.gap,
            offcanvas && styles.gapOffcanvas,
            icon && (padded ? styles.gapIconPadded : styles.gapIcon),
            instant && styles.instant,
          )}
        />
        <SidebarPanel
          data-slot="sidebar-container"
          data-side={side}
          {...sx}
          {...props}
          onPointerEnter={(event) => {
            onPointerEnter?.(event)
            if (offcanvas) setPeeking(true)
          }}
          onPointerLeave={(event) => {
            onPointerLeave?.(event)
            if (offcanvas) setPeeking(false)
          }}
          className={clsx(sx.className, className)}
          style={{ ...sx.style, ...style }}
        >
          <div
            data-sidebar="sidebar"
            data-slot="sidebar-inner"
            {...stylex.props(
              styles.inner,
              variant === "floating" && styles.innerFloating,
              variant === "inset" && styles.innerInset,
            )}
          >
            {children}
          </div>
        </SidebarPanel>
      </div>
    </SidebarPanelContext>
  )
}

function SidebarPanel({ render, ref, ...props }: useRender.ComponentProps<"div">) {
  return useRender({ defaultTagName: "div", render, ref, props })
}

type SidebarTriggerProps = ComponentProps<typeof Button> & {
  peek?: boolean
}

function SidebarTrigger({
  onClick,
  onPointerEnter,
  onPointerLeave,
  peek = false,
  ...props
}: SidebarTriggerProps) {
  const { toggleSidebar, open, isMobile, setPeeking } = useSidebar()
  const peeks = peek && !open && !isMobile

  return (
    <Button
      data-slot="sidebar-trigger"
      variant="ghost"
      size="icon-sm"
      aria-label="Toggle sidebar"
      onClick={(event) => {
        onClick?.(event)
        toggleSidebar()
      }}
      onPointerEnter={(event) => {
        onPointerEnter?.(event)
        if (peeks) setPeeking(true)
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event)
        if (peeks) setPeeking(false)
      }}
      {...props}
    >
      <PanelLeft />
    </Button>
  )
}

function SidebarRail({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"button"> & Overrides) {
  const { toggleSidebar, state } = useSidebar()
  const { side, collapsible } = use(SidebarPanelContext)
  const collapsed = state === "collapsed"
  const offcanvas = collapsed && collapsible === "offcanvas"
  const left = side === "left"
  const sx = stylex.props(
    styles.rail,
    left ? styles.railLeft : styles.railRight,
    collapsed && (left ? styles.railLeftCollapsed : styles.railRightCollapsed),
    offcanvas && styles.railOffcanvas,
    offcanvas && (left ? styles.railLeftOffcanvas : styles.railRightOffcanvas),
    xstyle,
  )

  return useRender({
    defaultTagName: "button",
    render,
    ref,
    props: {
      type: "button",
      "data-slot": "sidebar-rail",
      "aria-label": "Toggle sidebar",
      tabIndex: -1,
      onClick: toggleSidebar,
      title: "Toggle sidebar",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

type SidebarInsetProps = useRender.ComponentProps<"main"> & Overrides

function SidebarInset({ render, ref, style, xstyle, className, ...props }: SidebarInsetProps) {
  const { variant, state } = useSidebar()
  const inset = variant === "inset"
  const sx = stylex.props(
    styles.inset,
    inset && styles.insetVariant,
    inset && state === "collapsed" && styles.insetVariantCollapsed,
    xstyle,
  )

  return useRender({
    defaultTagName: "main",
    render,
    ref,
    props: {
      "data-slot": "sidebar-inset",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function SidebarInput({ xstyle, ...props }: ComponentProps<typeof Input>) {
  return <Input data-slot="sidebar-input" xstyle={[styles.input, xstyle]} {...props} />
}

function SidebarHeader({
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  return (
    <VStack
      data-slot="sidebar-header"
      gap={2}
      padding={2}
      xstyle={xstyle}
      className={className}
      {...props}
    />
  )
}

function SidebarFooter({
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  return (
    <VStack
      data-slot="sidebar-footer"
      gap={2}
      padding={2}
      xstyle={xstyle}
      className={className}
      {...props}
    />
  )
}

function SidebarSeparator({ xstyle, ...props }: ComponentProps<typeof Separator>) {
  return <Separator data-slot="sidebar-separator" xstyle={[styles.separator, xstyle]} {...props} />
}

function SidebarContent({
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const { iconMode } = use(SidebarPanelContext)
  return (
    <VStack
      data-slot="sidebar-content"
      grow
      scrollable
      xstyle={[iconMode && styles.contentIcon, xstyle]}
      className={clsx("no-scrollbar", className)}
      {...props}
    />
  )
}

function SidebarGroup({
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  return (
    <VStack
      data-slot="sidebar-group"
      padding={2}
      xstyle={[styles.group, xstyle]}
      className={className}
      {...props}
    />
  )
}

type SidebarGroupLabelProps = useRender.ComponentProps<"div"> & { xstyle?: StyleXStyles }

function SidebarGroupLabel({ xstyle, className, render, ...props }: SidebarGroupLabelProps) {
  const { iconMode } = use(SidebarPanelContext)
  const sx = stylex.props(
    styles.groupLabel,
    states.rowFocusRing,
    iconMode && styles.groupLabelIcon,
    xstyle,
  )

  return useRender({
    defaultTagName: "div",
    render,
    props: {
      "data-slot": "sidebar-group-label",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

type SidebarGroupActionProps = useRender.ComponentProps<"button"> & { xstyle?: StyleXStyles }

function SidebarGroupAction({ xstyle, className, render, ...props }: SidebarGroupActionProps) {
  const { iconMode } = use(SidebarPanelContext)
  const sx = stylex.props(
    styles.groupAction,
    states.rowFocusRing,
    iconMode && styles.hidden,
    xstyle,
  )

  return useRender({
    defaultTagName: "button",
    render,
    props: {
      type: "button",
      "data-slot": "sidebar-group-action",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

function SidebarGroupContent({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.groupContent, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "sidebar-group-content",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function SidebarMenu({ xstyle, className, ...props }: useRender.ComponentProps<"ul"> & Overrides) {
  return (
    <VStack<"ul">
      render={<ul />}
      data-slot="sidebar-menu"
      gap={1}
      xstyle={[styles.menu, xstyle]}
      className={className}
      {...props}
    />
  )
}

function SidebarMenuItem({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"li"> & Overrides) {
  const [size, setSize] = useState<SidebarMenuButtonSize>("default")
  const [hasAction, setHasAction] = useState(false)
  const value = useMemo(() => ({ size, setSize, hasAction, setHasAction }), [size, hasAction])
  const sx = stylex.props(styles.menuItem, sidebarMenuItem, xstyle)

  return (
    <SidebarMenuItemContext value={value}>
      {useRender({
        defaultTagName: "li",
        render,
        ref,
        props: {
          "data-slot": "sidebar-menu-item",
          ...sx,
          ...props,
          className: clsx(sx.className, className),
          style: { ...sx.style, ...style },
        },
      })}
    </SidebarMenuItemContext>
  )
}

type SidebarMenuButtonProps = useRender.ComponentProps<"button", { active: boolean }> & {
  isActive?: boolean
  variant?: SidebarMenuButtonVariant
  size?: SidebarMenuButtonSize
  tooltip?: string | ComponentProps<typeof TooltipContent>
  xstyle?: StyleXStyles
}

function SidebarMenuButton({
  render,
  isActive = false,
  variant = "default",
  size = "default",
  tooltip,
  xstyle,
  className,
  ...props
}: SidebarMenuButtonProps) {
  const { isMobile, state } = useSidebar()
  const { iconMode } = use(SidebarPanelContext)
  const item = use(SidebarMenuItemContext)

  useLayoutEffect(() => {
    item?.setSize(size)
  }, [item, size])

  const sx = stylex.props(
    styles.menuButton,
    states.rowFocusRing,
    menuButtonVariants[variant] ?? menuButtonVariants.default,
    menuButtonSizes[size] ?? menuButtonSizes.default,
    isActive && styles.rowActive,
    isActive && styles.rowActiveLabel,
    item?.hasAction && !iconMode && styles.menuButtonWithAction,
    iconMode && styles.menuButtonIcon,
    iconMode && size === "lg" && styles.menuButtonIconLg,
    xstyle,
  )

  const button = useRender({
    defaultTagName: "button",
    render: tooltip ? (
      <TooltipTrigger
        render={
          typeof render === "function" ? (props) => render(props, { active: isActive }) : render
        }
      />
    ) : (
      render
    ),
    state: { active: isActive },
    stateAttributesMapping: { active: (active) => (active ? { "data-active": "true" } : null) },
    props: {
      type: render ? undefined : "button",
      "data-slot": "sidebar-menu-button",
      "data-sidebar": "menu-button",
      "data-variant": variant,
      "data-size": size,
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })

  if (!tooltip) return button

  const content = typeof tooltip === "string" ? { children: tooltip } : tooltip

  return (
    <Tooltip>
      {button}
      {state === "collapsed" && !isMobile && (
        <TooltipContent side="right" align="center" {...content} />
      )}
    </Tooltip>
  )
}

type SidebarMenuActionProps = useRender.ComponentProps<"button"> & {
  showOnHover?: boolean
  xstyle?: StyleXStyles
}

function SidebarMenuAction({
  xstyle,
  className,
  render,
  showOnHover = false,
  ...props
}: SidebarMenuActionProps) {
  const { iconMode } = use(SidebarPanelContext)
  const item = use(SidebarMenuItemContext)

  useLayoutEffect(() => {
    if (!item) return
    item.setHasAction(true)
    return () => item.setHasAction(false)
  }, [item])

  const sx = stylex.props(
    styles.menuAction,
    states.rowFocusRing,
    menuActionTop[item?.size ?? "default"],
    showOnHover && styles.menuActionShowOnHover,
    iconMode && styles.hidden,
    xstyle,
  )

  return useRender({
    defaultTagName: "button",
    render,
    props: {
      type: render ? undefined : "button",
      "data-slot": "sidebar-menu-action",
      "data-sidebar": "menu-action",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

function SidebarMenuBadge({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const { iconMode } = use(SidebarPanelContext)
  const item = use(SidebarMenuItemContext)
  const sx = stylex.props(
    styles.menuBadge,
    menuActionTop[item?.size ?? "default"],
    iconMode && styles.hidden,
    xstyle,
  )

  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "sidebar-menu-badge",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

type SidebarMenuSkeletonProps = useRender.ComponentProps<"div"> & Overrides & { showIcon?: boolean }

function SidebarMenuSkeleton({
  render,
  ref,
  style,
  xstyle,
  className,
  showIcon = false,
  ...props
}: SidebarMenuSkeletonProps) {
  const id = useId()
  let hash = 0
  for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) % 41
  const width = `${50 + hash}%`
  const sx = stylex.props(styles.menuSkeleton, xstyle)

  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "sidebar-menu-skeleton",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
      children: (
        <>
          {showIcon && (
            <Skeleton data-slot="sidebar-menu-skeleton-icon" xstyle={styles.menuSkeletonIcon} />
          )}
          <Skeleton
            data-slot="sidebar-menu-skeleton-text"
            xstyle={styles.menuSkeletonText}
            style={{ maxWidth: width }}
          />
        </>
      ),
    },
  })
}

function SidebarMenuSub({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"ul"> & Overrides) {
  const { iconMode } = use(SidebarPanelContext)
  const sx = stylex.props(styles.menuSub, iconMode && styles.hidden, xstyle)

  return useRender({
    defaultTagName: "ul",
    render,
    ref,
    props: {
      "data-slot": "sidebar-menu-sub",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function SidebarMenuSubItem({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"li"> & Overrides) {
  const sx = stylex.props(styles.menuSubItem, xstyle)
  return useRender({
    defaultTagName: "li",
    render,
    ref,
    props: {
      "data-slot": "sidebar-menu-sub-item",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

type SidebarMenuSubButtonProps = useRender.ComponentProps<"a", { active: boolean }> & {
  size?: SidebarMenuSubButtonSize
  isActive?: boolean
  xstyle?: StyleXStyles
}

function SidebarMenuSubButton({
  render,
  size = "md",
  isActive = false,
  xstyle,
  className,
  ...props
}: SidebarMenuSubButtonProps) {
  const { iconMode } = use(SidebarPanelContext)
  const sx = stylex.props(
    styles.menuSubButton,
    states.rowFocusRing,
    menuSubButtonSizes[size] ?? menuSubButtonSizes.md,
    isActive && styles.rowActive,
    iconMode && styles.hidden,
    xstyle,
  )

  return useRender({
    defaultTagName: "a",
    render,
    state: { active: isActive },
    stateAttributesMapping: { active: (active) => (active ? { "data-active": "true" } : null) },
    props: {
      "data-slot": "sidebar-menu-sub-button",
      "data-size": size,
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

export {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupAction,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInput,
  SidebarInset,
  SidebarMenu,
  SidebarMenuAction,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSkeleton,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarProvider,
  SidebarRail,
  SidebarSeparator,
  SidebarTrigger,
  sidebarStyles,
  useSidebar,
  type SidebarCollapsible,
  type SidebarMenuButtonSize,
  type SidebarMenuButtonVariant,
  type SidebarMenuSubButtonSize,
  type SidebarSide,
  type SidebarVariant,
}

Update the import paths to match your project setup.

Usage

app/layout.tsx
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/app-sidebar"

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <AppSidebar />
      <main>
        <SidebarTrigger />
        {children}
      </main>
    </SidebarProvider>
  )
}
components/app-sidebar.tsx
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
} from "@/components/ui/sidebar"

export function AppSidebar() {
  return (
    <Sidebar>
      <SidebarHeader />
      <SidebarContent>
        <SidebarGroup />
        <SidebarGroup />
      </SidebarContent>
      <SidebarFooter />
    </Sidebar>
  )
}

Composition

SidebarProvider
├── Sidebar
│   ├── SidebarHeader
│   ├── SidebarContent
│   │   ├── SidebarGroup
│   │   │   ├── SidebarGroupLabel
│   │   │   ├── SidebarGroupAction
│   │   │   ├── SidebarGroupContent
│   │   │   └── SidebarMenu
│   │   │       ├── SidebarMenuItem
│   │   │       │   ├── SidebarMenuButton
│   │   │       │   ├── SidebarMenuAction
│   │   │       │   └── SidebarMenuBadge
│   │   │       └── SidebarMenuItem
│   │   │           ├── SidebarMenuButton
│   │   │           └── SidebarMenuSub
│   │   │               ├── SidebarMenuSubItem
│   │   │               └── SidebarMenuSubItem
│   │   └── SidebarGroup
│   │       └── SidebarMenu
│   │           ├── SidebarMenuItem
│   │           └── SidebarMenuItem
│   ├── SidebarFooter
│   └── SidebarRail
├── SidebarInset
└── SidebarTrigger

Above 48rem the Sidebar is a fixed column beside an in-flow spacer that animates its width to push the page over. Below it, the same children render inside a Sheet on the same side.

The variant prop is on SidebarProvider, not on Sidebar as in shadcn. The inset variant styles the wrapper and the SidebarInset beside the sidebar as well, and neither can see a sibling's prop.

There is no RTL section. carbon does not support RTL yet, so the offcanvas offset and the rail use LTR physical properties.

Structure

  • SidebarProvider handles collapsible state and provides sidebar context to child components.
  • Sidebar is the main collapsible sidebar panel.
  • SidebarHeader is sticky at the top. Use it for branding, titles, or workspace switchers.
  • SidebarFooter is sticky at the bottom. Use it for user menus, settings, or actions.
  • SidebarContent is the scrollable region between the header and footer.
  • SidebarGroup groups related navigation with optional label, action, and content areas.
  • SidebarMenu and SidebarMenuItem hold links, badges, actions, and nested submenus.
  • SidebarRail is a strip along the sidebar's edge that toggles it.
  • SidebarInset wraps the main content when using the inset variant.
  • SidebarTrigger toggles the sidebar open or collapsed.

SidebarProvider

Wrap the layout in a SidebarProvider. It holds the open state for the desktop panel and the mobile sheet, persists the desktop one in a carbon-sidebar cookie, and binds ⌘B on Mac and Ctrl+B on Windows.

PropTypeDefault
defaultOpenbooleantrue
openboolean—
onOpenChange(open: boolean) => void—
variant"sidebar" | "floating" | "inset""sidebar"

Width

The panel is 16rem wide, 3rem as an icon rail and 18rem as a sheet. The first two are custom properties on the wrapper. Set them with style on the provider; they are plain custom properties, so every part inside reads them.

const wide = { "--carbon-sidebar-width": "20rem", "--carbon-sidebar-width-icon": "4rem" }

<SidebarProvider style={wide as CSSProperties}>
  <Sidebar />
</SidebarProvider>

Keyboard shortcut

SIDEBAR_KEYBOARD_SHORTCUT in sidebar.tsx is the key that toggles the sidebar with ⌘ or Ctrl held.

components/ui/sidebar.tsx
const SIDEBAR_KEYBOARD_SHORTCUT = "b"

Sidebar renders the collapsible panel.

PropTypeDefault
side"left" | "right""left"
collapsible"offcanvas" | "icon" | "none""offcanvas"
collapsibleDescription
offcanvasA collapsible sidebar that slides in from the left or right.
iconA sidebar that collapses to icons.
noneA non-collapsible sidebar.

With variant="inset" on the provider, wrap the main content in a SidebarInset. It draws the panel the page is raised in, out of the tray the sidebar sits on, and moves over when the sidebar collapses.

<SidebarProvider variant="inset">
  <Sidebar />
  <SidebarInset>
    <main>{children}</main>
  </SidebarInset>
</SidebarProvider>

useSidebar

Use the useSidebar hook to control the sidebar.

import { useSidebar } from "@/components/ui/sidebar"

export function AppSidebar() {
  const { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar } = useSidebar()
}
PropertyTypeDescription
state"expanded" | "collapsed"The current state of the sidebar.
openbooleanWhether the sidebar is open.
setOpen(open: boolean) => voidSets the open state of the sidebar.
openMobilebooleanWhether the sidebar is open on mobile.
setOpenMobile(open: boolean) => voidSets the open state of the sidebar on mobile.
isMobilebooleanWhether the sidebar is on mobile.
toggleSidebar() => voidToggles the sidebar on desktop and mobile.

SidebarHeader

Use the SidebarHeader component to add a sticky header to the sidebar.

components/app-sidebar.tsx
<Sidebar>
  <SidebarHeader>
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger render={<SidebarMenuButton />}>
            Select Workspace
            <ChevronDown {...stylex.props(styles.trailing)} />
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem>Acme Inc</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarHeader>
</Sidebar>

A menu button that is an open menu's trigger keeps its hover fill while the menu is open. The aria-expanded rule in interop.css does this.

SidebarFooter

Use the SidebarFooter component to add a sticky footer to the sidebar.

<Sidebar>
  <SidebarFooter>
    <SidebarMenu>
      <SidebarMenuItem>
        <SidebarMenuButton>
          <User2 /> Username
        </SidebarMenuButton>
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarFooter>
</Sidebar>

SidebarContent

SidebarContent wraps the content of the sidebar and scrolls. Put your SidebarGroup components in it.

<Sidebar>
  <SidebarContent>
    <SidebarGroup />
    <SidebarGroup />
  </SidebarContent>
</Sidebar>

SidebarGroup

Use the SidebarGroup component to create a section within the sidebar.

A SidebarGroup has a SidebarGroupLabel, a SidebarGroupContent and an optional SidebarGroupAction. Give the action an aria-label. It is an icon-only button, so there is no text for a screen reader to announce.

<SidebarGroup>
  <SidebarGroupLabel>Application</SidebarGroupLabel>
  <SidebarGroupAction aria-label="Add project">
    <Plus />
  </SidebarGroupAction>
  <SidebarGroupContent></SidebarGroupContent>
</SidebarGroup>

To make a SidebarGroup collapsible, wrap it in a Collapsible and render the label as the trigger. collapsibleStyles.chevron turns the chevron with the panel.

<Collapsible defaultOpen>
  <SidebarGroup>
    <SidebarGroupLabel render={<CollapsibleTrigger />}>
      Help
      <ChevronDown {...stylex.props(collapsibleStyles.chevron, styles.trailing)} />
    </SidebarGroupLabel>
    <CollapsibleContent>
      <SidebarGroupContent />
    </CollapsibleContent>
  </SidebarGroup>
</Collapsible>

SidebarMenu

Use SidebarMenu to build a menu within a SidebarGroup.

<SidebarMenu>
  {projects.map((project) => (
    <SidebarMenuItem key={project.name}>
      <SidebarMenuButton render={<a href={project.url} />}>
        <project.icon />
        <span {...stylex.props(sidebarStyles.truncate)}>{project.name}</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  ))}
</SidebarMenu>

SidebarMenuButton

SidebarMenuButton renders a menu button within a SidebarMenuItem.

It renders a button by default. Use the render prop to render a different component, such as a Link or an a tag.

Use the isActive prop to mark a menu item as active.

<SidebarMenuButton render={<a href="#" />} isActive>
  Home
</SidebarMenuButton>

Put a label that can outgrow the row in a span with sidebarStyles.truncate. StyleX has no [&>span:last-child], so the row cannot select the label. The label takes the truncation style itself.

Pass tooltip to label the row while the sidebar is collapsed to icons. A string is the label; an object is TooltipContent's props. The tooltip renders only in that state.

<SidebarMenuButton tooltip="Playground">
  <SquareTerminal />
  <span>Playground</span>
</SidebarMenuButton>

SidebarMenuAction

SidebarMenuAction renders a menu action within a SidebarMenuItem. Give it an aria-label.

<SidebarMenuItem>
  <SidebarMenuButton render={<a href="#" />}>
    <Home />
    <span>Home</span>
  </SidebarMenuButton>
  <SidebarMenuAction aria-label="Add project">
    <Plus />
  </SidebarMenuAction>
</SidebarMenuItem>

Pass showOnHover to hide the action until the row is hovered or holds focus. Below 48rem it always shows; there is no pointer to hover with.

The action registers with its SidebarMenuItem after mount, and the button then pads its end so a long label truncates before the icon. Prerendered HTML ships without that padding, so a label that reaches the action moves by 2rem on hydration.

SidebarMenuSub

SidebarMenuSub renders a submenu within a SidebarMenu.

<SidebarMenuItem>
  <SidebarMenuButton />
  <SidebarMenuSub>
    <SidebarMenuSubItem>
      <SidebarMenuSubButton />
    </SidebarMenuSubItem>
  </SidebarMenuSub>
</SidebarMenuItem>

SidebarMenuBadge

SidebarMenuBadge renders a badge within a SidebarMenuItem.

<SidebarMenuItem>
  <SidebarMenuButton />
  <SidebarMenuBadge>24</SidebarMenuBadge>
</SidebarMenuItem>

SidebarMenuSkeleton

SidebarMenuSkeleton renders a skeleton for a SidebarMenu. The text bar is between 50% and 90% wide, chosen from the element's id so the server and the client agree.

<SidebarMenu>
  {Array.from({ length: 5 }).map((_, index) => (
    <SidebarMenuItem key={index}>
      <SidebarMenuSkeleton />
    </SidebarMenuItem>
  ))}
</SidebarMenu>

SidebarTrigger

Use the SidebarTrigger component to render a button that toggles the sidebar. It is a ghost icon-sm Button and takes every prop one does.

import { useSidebar } from "@/components/ui/sidebar"

export function CustomTrigger() {
  const { toggleSidebar } = useSidebar()

  return <button onClick={toggleSidebar}>Toggle Sidebar</button>
}

SidebarRail

SidebarRail renders a rail within a Sidebar that toggles the sidebar. It is out of the tab order, so keyboard users toggle the sidebar with the trigger.

<Sidebar>
  <SidebarHeader />
  <SidebarContent>
    <SidebarGroup />
  </SidebarContent>
  <SidebarFooter />
  <SidebarRail />
</Sidebar>

Controlled sidebar

Use the open and onOpenChange props to control the sidebar.

export function AppSidebar() {
  const [open, setOpen] = useState(false)

  return (
    <SidebarProvider open={open} onOpenChange={setOpen}>
      <Sidebar />
    </SidebarProvider>
  )
}

Theming

The sidebar reads its own tokens, so it can differ from the page around it. They are in tokens.stylex.ts beside the rest.

theme/tokens.stylex.ts
sidebar: "light-dark(oklch(0.985 0 0), oklch(0.205 0 0))",
sidebarForeground: "light-dark(oklch(0.145 0 0), oklch(0.985 0 0))",
sidebarPrimary: "light-dark(oklch(0.205 0 0), oklch(0.922 0 0))",
sidebarPrimaryForeground: "light-dark(oklch(0.985 0 0), oklch(0.205 0 0))",
sidebarAccent: "light-dark(oklch(0.95 0 0), oklch(0.269 0 0))",
sidebarAccentForeground: "light-dark(oklch(0.205 0 0), oklch(0.985 0 0))",
sidebarBorder: "light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))",

Styling

Every part takes xstyle. A part that changes with the sidebar's state reads that state from context, so there is no group-data-[collapsible=icon] to key on. Read useSidebar and pass a conditional style instead.

const { state } = useSidebar()

<SidebarGroup xstyle={[state === "collapsed" && styles.hidden]} />

API reference

Pass render to replace a part's element or compose it with another component.

Every part renders a plain element, or Base UI's useRender where it takes render. Each part also takes xstyle and className.

SidebarProvider

PropTypeDefault
defaultOpenbooleantrue
openboolean—
onOpenChange(open: boolean) => void—
variant"sidebar" | "floating" | "inset""sidebar"
PropTypeDefault
side"left" | "right""left"
collapsible"offcanvas" | "icon" | "none""offcanvas"

SidebarMenuButton

PropTypeDefault
renderReactElement | (props, state) => ReactElement—
isActivebooleanfalse
variant"default" | "outline""default"
size"default" | "sm" | "lg""default"
tooltipstring | TooltipContentProps—

SidebarMenuAction

PropTypeDefault
renderReactElement | (props, state) => ReactElement—
showOnHoverbooleanfalse

SidebarMenuSubButton

PropTypeDefault
renderReactElement | (props, state) => ReactElement—
isActivebooleanfalse
size"sm" | "md""md"

SidebarMenuSkeleton

PropTypeDefault
showIconbooleanfalse

sidebarStyles

The style maps behind the menu buttons, exported so the same row can be put on another element.

KeyDescription
menuButtonThe row: box, hover fill, focus ring, icon size
menuButtonVariantsOne style per variant
menuButtonSizesOne style per size, with the height and type
menuSubButtonThe nested row, one step shorter
truncateEllipsis for the label span in a row

Data attributes

AttributeOnEffect
data-slot="sidebar-wrapper"the provider's div
data-variantwrapper, sidebarMirrors the provider's variant
data-slot="sidebar"the sidebar rootAbsent below 48rem, where the sheet renders
data-statethe sidebar root"expanded" or "collapsed"
data-collapsiblethe sidebar rootThe collapsible prop while collapsed, "" while open
data-sideroot, containerMirrors side
data-slot="sidebar-gap"the in-flow spacerAnimates its width with the panel
data-slot="sidebar-container"the fixed panelWhere xstyle and className land
data-slot="sidebar-inner"the panel's surfaceFill, and the floating variant's raised shadow
data-sidebar="sidebar", data-mobilethe sheet popupWritten beside the sheet's own data-slot
data-slot="sidebar-trigger"the trigger
data-slot="sidebar-rail"the rail
data-slot="sidebar-inset"the main
data-slot="sidebar-header", -footer, -contenteach region
data-slot="sidebar-group", -label, -action, -contenteach group part
data-slot="sidebar-menu", -item, -sub, -sub-itemeach list part
data-slot="sidebar-menu-button"a rowReplaced by the trigger's slot when rendered through render
data-sidebar="menu-button"a rowWhere interop.css keys the open-menu fill
data-variant, data-size, data-activea row, a nested rowMirror the props; data-active is present only when true
data-slot="sidebar-menu-action"an actionReplaced by the trigger's slot when rendered through render
data-sidebar="menu-action"an actionWhere interop.css keys the open-menu opacity
data-slot="sidebar-menu-badge"a badge
data-slot="sidebar-menu-skeleton", -icon, -textthe skeleton parts
aria-haspopup, aria-expandeda row that opens a menuBase UI writes them