Sidebar

Two sidebars from one set of parts: the app frame, a transparent rail beside a rounded content panel, and a static nav that lives inside a page. Either can be pinned to the left or the right, two can share one frame, and a rail collapses to icons or to nothing at all.

src/components/ui/sidebar.tsx

About

From the component's own source, where it was written.

An inset application frame: a transparent rail beside a rounded content panel, both floating on the page background.

Inset is the only layout. That is a decision, not an omission: a flush sidebar needs a border that the inset panel already provides by being a separate surface, and supporting one shape well is what keeps the geometry exact.

A rail collapses to icons by default, or to nothing at all — see collapsedTo. Neither slides over the content: an overlay needs a scrim and focus containment, and a panel that takes no width needs neither.

The grid, in one place:

gutter 8px p-2 on the frame, gap-2 between rail and panel, and px-2 inside the rail — one value everywhere rail 52px 36 + 2×8: one control unit plus that same gutter either side. The gutter is not decoration — a menu row's focus ring is a 3px box-shadow, and SidebarContent scrolls, so a rail sized to the button exactly would clip the ring against its padding box on both edges expanded 256px w-64 inset 10px px-2.5 on a menu row — (36 − 16) / 2, which holds a 16px icon on the rail's centre line whether open or collapsed, so nothing shifts sideways as the label appears

Install

npx astralyx-ui add sidebar

Usage

import {
  Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel,
  SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton,
  SidebarMenuItem, SidebarProvider, SidebarTrigger,
} from '@/components/ui/sidebar'

<SidebarProvider>
  <Sidebar>
    <SidebarHeader>…</SidebarHeader>
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Workspace</SidebarGroupLabel>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton icon={<Home />} isActive>Home</SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarGroup>
    </SidebarContent>
  </Sidebar>

  <SidebarInset>
    <SidebarTrigger />
    …
  </SidebarInset>
</SidebarProvider>

Composer

Change the props and the source below follows.

Overview
The panel is the only raised surface, so the rail reads as part of the page.
Props
tsx
<SidebarProvider defaultOpen={true}>
  <Sidebar>
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Workspace</SidebarGroupLabel>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton icon={<Home />} isActive>Home</SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarGroup>
    </SidebarContent>
  </Sidebar>

  <SidebarInset>
    <SidebarTrigger />
  </SidebarInset>
</SidebarProvider>

Examples

Expanded

One on each side

Inside a page, static

Collapsed to icons

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

SidebarProvider

The frame. Owns the open state and paints the page background the rail and panel float on.

Extends ComponentProps<'div'>.

PropTypeDefaultDescription
variantSidebarVariant'app'app is the product frame that owns the window, collapses to a rail and paints its own fixed ground. page is navigation inside a page — a settings nav, a docs section — which never collapses, has no trigger and no shortcut, takes its height from whatever contains it, and is painted in the page theme. One prop rather than three booleans, because they are not independent: a sidebar that does not own the window has nothing to collapse into and no ground of its own to paint.
openSidebarOpendefaultOpen: trueControlled or uncontrolled rail state. A boolean sets both sides; an object like { left: true, right: false } sets the ones it names, and a side the caller does not name keeps looking after itself. onOpenChange is told which side moved. Below the md breakpoint both rails are pinned collapsed and these are ignored — at 36px it is already the mobile layout, so there is nothing to overlay.
defaultOpenSidebarOpentrueControlled or uncontrolled rail state. A boolean sets both sides; an object like { left: true, right: false } sets the ones it names, and a side the caller does not name keeps looking after itself. onOpenChange is told which side moved. Below the md breakpoint both rails are pinned collapsed and these are ignored — at 36px it is already the mobile layout, so there is nothing to overlay.
onOpenChange(open: boolean, side: SidebarSide) => voiddefaultOpen: trueControlled or uncontrolled rail state. A boolean sets both sides; an object like { left: true, right: false } sets the ones it names, and a side the caller does not name keeps looking after itself. onOpenChange is told which side moved. Below the md breakpoint both rails are pinned collapsed and these are ignored — at 36px it is already the mobile layout, so there is nothing to overlay.

Sidebar

The rail itself — transparent by design, so the frame's background reads as one continuous surface behind it and only the content panel looks raised.

Extends ComponentProps<'aside'>.

PropTypeDefaultDescription
positionSidebarSide'left'Which edge to pin to. Two sidebars can share one provider — navigation on the left, an inspector on the right — and they open independently. The order is set from the prop, not from the markup, so a right sidebar written before the content still lands after it.
collapsedTo'icon' | 'nothing''icon'What is left when it collapses. icon keeps the 52px rail, which is right for navigation: every destination stays one click away and the layout never reflows. nothing gives the width back, which is right for a detail panel — an inspector nobody is reading should not cost a strip of the window. Hidden rather than unmounted, so the trigger’s aria-controls keeps resolving.

SidebarHeader

Fixed top section — a product mark, a workspace switcher.

Extends ComponentProps<'div'>.

SidebarContent

The scrolling middle.

Extends ComponentProps<'div'>.

SidebarFooter

Fixed bottom section — an account row, a sign-out.

Extends ComponentProps<'div'>.

SidebarGroup

A titled run of menu rows.

Extends ComponentProps<'div'>.

SidebarGroupLabel

A section heading. It collapses to nothing rather than to an icon, since a heading has no icon to fall back to — and it gives its height back (and the group's gap under it), so on the icon rail the icons sit in one even column instead of in clumps with holes where the headings were. Height and opacity ease with the rail's own width transition, and return when it expands.

Extends ComponentProps<'div'>.

SidebarMenu

Extends ComponentProps<'ul'>.

SidebarMenuItem

Extends ComponentProps<'li'>.

SidebarMenuButton

One row. Renders a button by default, or whatever it is given via asChild — a router link, usually.

Extends ComponentProps<'button'>.

PropTypeDefaultDescription
asChildbooleanfalseRender a router link instead of a button, keeping the row styling. Pass the link with the label as its children — <SidebarMenuButton asChild icon={<Home />}><Link to="/">Home</Link></SidebarMenuButton> — and the icon, label and trailing slot are placed inside it.
isActivebooleanfalseMarks the current page. Fills the row with the secondary surface.
iconReactNode—Leading icon, sized to 16px by the row. It stays on the rail centre line in both states, so nothing shifts sideways as the label appears.
trailingReactNode—A count or status, hidden with the label when the rail collapses.
tooltipReactNode—Shown on hover while collapsed. Defaults to the row label. The label itself becomes sr-only rather than unmounting, so the accessible name survives the collapse.

SidebarTrigger

Toggles the rail. Inert below md, where the rail is already the layout.

Extends ComponentProps<'button'>.

PropTypeDefaultDescription
positionSidebarSide'left'Which panel the button toggles, and what to call it. Two triggers in one header both announcing “Expand sidebar” is a coin toss for anyone who cannot see which is which, so label names the panel instead.
labelstring'left' / '<side> sidebar'Which panel the button toggles, and what to call it. Two triggers in one header both announcing “Expand sidebar” is a coin toss for anyone who cannot see which is which, so label names the panel instead.

SidebarInset

The content panel — an ordinary themed surface on the rail's fixed black ground, which is what makes the layout read as inset.

Extends ComponentProps<'main'>.

Hooks

HookSignatureDescription
useSidebar() => SidebarContextRail state for custom chrome. locked is true below md, where SidebarTrigger renders nothing.

Types

TypeDefinitionDescription
SidebarVariant
'app' | 'page'
Which of the two sidebars this is.
SidebarSide
'left' | 'right'
Which edge a sidebar is pinned to.
SidebarOpen
boolean | Partial<Record<SidebarSide, boolean>>
Open state per side, or one value for both.

Behaviour

TopicDetailDescription
grid8 / 52 / 256pxGutter, collapsed rail and expanded width. The rail is a 36px control unit plus the same 8px gutter either side — that gutter is load-bearing, since the scrolling content clips a row focus ring at its padding box. The 10px row inset is (36 − 16) / 2, which holds the icon on the rail centre line in both states.
shortcut⌘B / Ctrl-BToggles the rail while the frame is mounted and the viewport is at least md.