Skip to content

Sidebar

Organize your app’s main navigation in a sidebar that collapses to icons or hides.

import { Sidebar, SidebarCard, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use a sidebar for the navigation of an app with many destinations: groups of pages, projects, settings. Collapse it to icons to give the page room, or off the canvas entirely. Don't use it for a marketing site, where a Navigation Menu previews sections from a top bar, or for a handful of views of one thing, where Tabs keep them in place.

import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarTrigger,
} from '@youj/react'
import { HomeIcon, SendIcon } from '@youj/icons'

export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Sidebar collapsible="icon">
        <SidebarContent>
          <SidebarGroup>
            <SidebarGroupLabel>Platform</SidebarGroupLabel>
            <SidebarMenu>
              <SidebarMenuItem>
                <SidebarMenuButton isActive tooltip="Home" render={<a href="/" aria-current="page" />}>
                  <HomeIcon size={16} />
                  <span>Home</span>
                </SidebarMenuButton>
              </SidebarMenuItem>
              <SidebarMenuItem>
                <SidebarMenuButton tooltip="Deployments" render={<a href="/deployments" />}>
                  <SendIcon size={16} />
                  <span>Deployments</span>
                </SidebarMenuButton>
              </SidebarMenuItem>
            </SidebarMenu>
          </SidebarGroup>
        </SidebarContent>
      </Sidebar>
      <SidebarInset>
        <header>
          <SidebarTrigger />
        </header>
        {children}
      </SidebarInset>
    </SidebarProvider>
  )
}

Build an app frame puts this sidebar together with a top bar, the account menu and search, in one file. SidebarProvider wraps the whole shell and holds the open state. Sidebar is the panel, with a SidebarHeader, a scrolling SidebarContent and a SidebarFooter. SidebarInset is the page beside it, rendered as your main. Menu buttons render links through render, so they work with your router's link component.

The previews on this page sit inside a fixed-height frame. In an app, the provider fills the viewport and the sidebar stays in place while the page scrolls.

Collapsing

collapsible decides what collapsing does: icon narrows the sidebar to a rail of icons, offcanvas (the default) slides it out of view, and none keeps it fixed open. The SidebarTrigger button, ⌘B or Ctrl+B, and a click on the SidebarRail along its edge all toggle it. Collapsing never remounts an item, so keyboard focus stays where it was.

Pass defaultOpen to choose the starting state, or open and onOpenChange to control it. Every toggle also writes a sidebar_state cookie, so a server can render the sidebar the way it was left:

app/layout.tsx
import { cookies } from 'next/headers'

export default async function Layout({ children }: { children: React.ReactNode }) {
  const defaultOpen = (await cookies()).get('sidebar_state')?.value !== 'false'
  return <SidebarProvider defaultOpen={defaultOpen}>{children}</SidebarProvider>
}

useSidebar() reads the state from anywhere inside the provider: state ('expanded' or 'collapsed'), open, setOpen, openMobile, setOpenMobile, isMobile and toggleSidebar.

The current page

Mark the current page's button with isActive and give its link aria-current="page": isActive places the key, and aria-current tells assistive technology. SidebarContent's indicator sets how the key looks: raised (the default, the key's plane on a hairline), fill (a quiet gray fill, shadcn's look), mark (a short accent bar on the inline-start edge) or none, when your rows mark the page themselves. A tile that stays raised whatever the page (a workspace switcher, a rail's current area) is SidebarMenuButton variant="raised". There is one key for the whole sidebar, so it glides between groups as the page changes. When the current page sits inside a closed nested group, or the sidebar is down to icons, the key moves to that group's own item. Keep nested panels mounted (keepMounted on CollapsibleContent, as in the first preview) so the key can find a page in a closed group.

  • Nested pages: wrap an item in a Collapsible rendered as the SidebarMenuItem, and list its pages in a SidebarMenuSub of SidebarMenuSubButtons. CollapsibleChevron turns with it.
  • Badges: SidebarMenuBadge after a button shows a count or a shortcut on its end edge, every row's on one column. variant="pill" sets it on a well, and tone="danger" makes it loud, for something failed or overdue.
  • Cards: SidebarCard is a raised card for the sidebar's foot or top (a setup checklist, a plan's meter, an upgrade). It steps aside while the sidebar is folded to icons.
  • Actions: SidebarMenuAction adds a small button beside an item, and SidebarGroupAction beside a group's label. showOnHover hides an item's action until the item is hovered or focused.
  • Loading: SidebarMenuSkeleton stands in for an item while it loads. Add showIcon for a leading icon.
  • Search: SidebarInput is an input sized for the sidebar, and it hides when the sidebar is down to icons.

Labels, badges and actions fade out before the width closes, and fade back in after it returns, so nothing squashes or truncates on the way. Group labels fold away, and nested lists close their height.

Nested groups

The Collapsible renders as the menu item and its trigger as the item's button, so the group keeps the row's size, key and tooltip:

import {
  Collapsible,
  CollapsibleChevron,
  CollapsibleContent,
  CollapsibleTrigger,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
} from '@youj/react'
import { FolderIcon } from '@youj/icons'

export function ProjectNav() {
  return (
    <SidebarMenu>
      <Collapsible defaultOpen render={<SidebarMenuItem />}>
        <CollapsibleTrigger render={<SidebarMenuButton tooltip="web" />}>
          <FolderIcon size={16} />
          <span>web</span>
          <CollapsibleChevron size={14} className="ms-auto text-fg-muted" />
        </CollapsibleTrigger>
        <CollapsibleContent keepMounted>
          <SidebarMenuSub>
            <SidebarMenuSubItem>
              <SidebarMenuSubButton href="/web/deployments" isActive aria-current="page">
                <span>Deployments</span>
              </SidebarMenuSubButton>
            </SidebarMenuSubItem>
            <SidebarMenuSubItem>
              <SidebarMenuSubButton href="/web/logs">
                <span>Logs</span>
              </SidebarMenuSubButton>
            </SidebarMenuSubItem>
          </SidebarMenuSub>
        </CollapsibleContent>
      </Collapsible>
    </SidebarMenu>
  )
}

SidebarMenuSubButton is a link; pass render={<Link href="/web/logs" />} for your router's. keepMounted lets the current page's key find a page in a closed group.

The account row

For the account at the foot of the sidebar, give Account Menu a SidebarMenuButton as its trigger. The whole row (avatar, name, email and chevron) opens the menu, and collapsed to icons it's the avatar alone, named by its tooltip:

import { AccountMenu, Avatar, AvatarFallback, SidebarFooter, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@youj/react'
import { ChevronsUpDownIcon } from '@youj/icons'

const user = { name: 'Ada Lovelace', email: 'ada@acme.com' }

export function AccountRow({ onSignOut }: { onSignOut: () => void }) {
  return (
    <SidebarFooter>
      <SidebarMenu>
        <SidebarMenuItem>
          <AccountMenu
            user={user}
            onSignOut={onSignOut}
            side="top"
            align="start"
            trigger={
              <SidebarMenuButton size="lg" tooltip={user.name} className="data-popup-open:bg-hover">
                <Avatar size="sm" className="size-8">
                  <AvatarFallback>AL</AvatarFallback>
                </Avatar>
                <span className="flex min-w-0 flex-1 flex-col leading-tight">
                  <span className="truncate font-medium text-fg">{user.name}</span>
                  <span className="truncate text-ui-sm text-fg-muted">{user.email}</span>
                </span>
                <ChevronsUpDownIcon size={14} className="ms-auto text-fg-muted" />
              </SidebarMenuButton>
            }
          />
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarFooter>
  )
}

Building your frame in Create? Its account card is this row, with the standard menu rows and the menu opening to the side when the sidebar is down to icons.

Header text on the icon rail

Collapsed to icons, a SidebarMenuButton shows only its first child: the rest fades out. So put the brand in the header as a menu button, mark first, and it collapses to the mark on its own:

<SidebarHeader>
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg" tooltip="Acme" render={<a href="/" />}>
        <AcmeLogo className="size-8" />
        <span className="truncate font-semibold">Acme</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
</SidebarHeader>

SidebarHeader and SidebarFooter are plain containers and leave anything else in them alone, since it may be something to keep on the rail. Hide other text there yourself with group-data-[collapsible=icon]:hidden, as in <p className="px-2 text-ui-sm group-data-[collapsible=icon]:hidden">Free plan</p>.

Mobile

Below the md breakpoint, the sidebar becomes a sheet: a modal dialog that slides in from its edge, with a focus trap. The trigger and ⌘B open it, and Escape or a tap outside closes it. After a navigation, close it with setOpenMobile(false) from useSidebar(). The same useIsMobile() hook is exported from @youj/react for your own layouts.

Reference

PropsdefaultOpen, open, onOpenChange… (14)

Props

SidebarProvider

SidebarProvider props
PropType
defaultOpenboolean

default true

true
openboolean–
onOpenChange(open: boolean) => void–
Sidebar props
PropType
side'left' | 'right'

'left' is the inline-start edge (the right, under RTL).

default 'left'

'left'
variant'sidebar' | 'floating' | 'inset'

default 'sidebar'

'sidebar'
collapsible'offcanvas' | 'icon' | 'none'

default 'offcanvas'

'offcanvas'
surfaceSidebarSurface
'solid''glass'

glass frosts the sidebar over whatever is behind it (a backdrop, an image), the phone's sheet too.

default 'solid'

'solid'

SidebarMenuButton

It renders a button, or your element through render.

SidebarMenuButton props
PropType
isActiveboolean

The current page: the sidebar's raised key sits under it.

default false

false
tooltipReactNode

Shown beside the item while the sidebar is collapsed to icons.

–
variant'default' | 'raised' | 'outline'

default 'default'

'default'
size'default' | 'sm' | 'lg'

default 'default'

'default'

SidebarMenuSubButton

It renders an a, or your element through render.

SidebarMenuSubButton props
PropType
size'sm' | 'md'

default 'md'

'md'
isActiveboolean

The current page: the sidebar's raised key sits under it.

default false

false

SidebarContent

SidebarContent props
PropType
indicatorSidebarIndicator
'raised''fill''mark''none'

How the current page is marked: a raised key (default), a quiet fill, an accent mark on the inline-start edge, or none when the rows mark it themselves. It glides from page to page.

default 'raised'

'raised'

SidebarMenuBadge

SidebarMenuBadge props
PropType
variant'plain' | 'pill'

pill sets the count on a well.

default 'plain'

'plain'
tone'default' | 'danger'

danger is the loud count: something failed or is overdue.

default 'default'

'default'

SidebarMenuAction and SidebarMenuSkeleton

SidebarMenuAction props
PropType
showOnHoverboolean

default false

false
SidebarMenuSkeleton props
PropType
showIconboolean

default false

false

The other parts add no props: SidebarHeader, SidebarFooter, SidebarCard, SidebarGroup, SidebarGroupContent and SidebarSeparator render divs, SidebarMenu and SidebarMenuSub render uls, and their items render lis. SidebarGroupLabel and SidebarGroupAction take render. SidebarTrigger takes the props of Button.

MotionCollapse and expand, labels out, labels in… · reduced motion

Motion

Motion tokens
MomentTokens
Collapse and expandthe width, frame by frame
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Labels outlabels, badges and actions, before the width closes
duration.instantease.standard

80ms · cubic-bezier(0.2, 0, 0, 1)

80mscubic-bezier(0.2, 0, 0, 1)
Labels inafter a fast delay, once the width is back
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Group labelsfold away as the menu below rises into their place
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Nested listsclose their height
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Off-canvasthe panel slides out as the space closes over it
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Current pagethe raised key glides across groups
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Collapsed tooltipthe yj-tooltip preset, on the inline-end side
duration.fastease.enter

140ms · cubic-bezier(0.16, 1, 0.3, 1)

140mscubic-bezier(0.16, 1, 0.3, 1)
Mobile sheetthe yj-sheet preset; it leaves on base
duration.slowease.enter

340ms · cubic-bezier(0.16, 1, 0.3, 1)

340mscubic-bezier(0.16, 1, 0.3, 1)

Reduced motion

The sidebar collapses and expands at once, the key jumps to the current page, and tooltips and the mobile sheet appear without moving.

Reduced motion

Everything still works, without movement: the sidebar snaps between widths, labels appear and disappear in place, the current-page key jumps, and tooltips and the mobile sheet appear at once.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • SidebarTrigger is a button named "Toggle sidebar", with aria-expanded and aria-keyshortcuts="Meta+B Control+B".
  • ⌘B and Ctrl+B toggle the sidebar, but never while you type in a field (it's bold there), and not on key repeat. The shortcut listens on the window but acts on one sidebar at a time: with several SidebarProviders on a page, the one you last clicked or focused in (the first on the page until then).
  • The rail is a pointer shortcut. It's hidden from assistive technology and out of the tab order, so the trigger is the one toggle.
  • Collapsed to icons, labels stay in the document: each item keeps its accessible name, and its tooltip shows the label to sighted users on hover and focus.
  • The sidebar renders plain containers. Wrap its menus in a nav with an aria-label to make your primary navigation a landmark.
  • On mobile, the sheet is a modal dialog named "Sidebar". It traps focus, closes on Escape, and returns focus to the trigger.