Sidebar
A composable, responsive container for application navigation and supporting content.
<script lang="ts">
import { Dialog, Sidebar } from "bits-ui";
import Calendar from "phosphor-svelte/lib/Calendar";
import Gear from "phosphor-svelte/lib/Gear";
import House from "phosphor-svelte/lib/House";
import MagnifyingGlass from "phosphor-svelte/lib/MagnifyingGlass";
import SidebarIcon from "phosphor-svelte/lib/Sidebar";
import Tray from "phosphor-svelte/lib/Tray";
import X from "phosphor-svelte/lib/X";
let openMobile = $state(false);
const items = [
{ title: "Home", icon: House },
{ title: "Inbox", icon: Tray },
{ title: "Calendar", icon: Calendar },
{ title: "Search", icon: MagnifyingGlass },
{ title: "Settings", icon: Gear }
];
</script>
{#snippet sidebarPanel(panelState: Sidebar.SidebarState, mobile: boolean)}
<Sidebar.Root
collapsible="icon"
class="group/sidebar relative flex h-full shrink-0 flex-col border-r transition-[width] duration-200 ease-linear motion-reduce:transition-none {mobile
? 'bg-background w-[18rem]'
: panelState === 'expanded'
? 'bg-muted/45 w-64'
: 'bg-muted/45 w-12'}"
>
<Sidebar.Content
class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden group-data-[state=collapsed]/sidebar:overflow-hidden"
>
<Sidebar.Group class="p-2">
<Sidebar.GroupLabel
class="text-foreground-alt mb-1 flex h-8 items-center whitespace-nowrap px-2 text-xs transition-[margin,opacity] duration-200 ease-linear group-data-[state=collapsed]/sidebar:-mt-9 group-data-[state=collapsed]/sidebar:opacity-0 motion-reduce:transition-none"
>
Application
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu class="space-y-1">
{#each items as item (item.title)}
{@const Icon = item.icon}
<Sidebar.MenuItem>
<Sidebar.MenuButton
aria-label={item.title}
title={panelState === "collapsed" && !mobile
? item.title
: undefined}
class="hover:bg-muted focus-visible:ring-foreground flex h-8 w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2"
>
<Icon class="size-4 shrink-0" />
<span
class="truncate whitespace-nowrap transition-opacity duration-100 ease-linear group-data-[state=collapsed]/sidebar:opacity-0 motion-reduce:transition-none"
>
{item.title}
</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
{#if !mobile}
<Sidebar.Rail
class="hover:bg-foreground/10 focus-visible:bg-foreground/10 absolute inset-y-0 -right-1 z-10 w-2 cursor-ew-resize focus-visible:outline-none"
aria-label="Toggle sidebar"
/>
{/if}
</Sidebar.Root>
{/snippet}
<Sidebar.Provider
bind:openMobile
class="bg-background relative flex h-[420px] w-full overflow-hidden rounded-lg border"
>
{#snippet children({ state, isMobile })}
{#if isMobile}
<Dialog.Root bind:open={openMobile}>
<Dialog.Portal>
<Dialog.Overlay
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 fixed inset-0 z-50 bg-black/45 motion-reduce:animate-none"
/>
<Dialog.Content
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left fixed inset-y-0 left-0 z-50 outline-none motion-reduce:animate-none"
>
<Dialog.Title class="sr-only">Application navigation</Dialog.Title>
<Dialog.Description class="sr-only">
Navigate to another section of the application.
</Dialog.Description>
<Dialog.Close
class="hover:bg-muted focus-visible:ring-foreground absolute right-2 top-2 z-10 inline-flex size-8 items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2"
aria-label="Close sidebar"
>
<X class="size-4" />
</Dialog.Close>
{@render sidebarPanel(state, true)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{:else}
{@render sidebarPanel(state, false)}
{/if}
<Sidebar.Inset class="bg-background min-w-0 flex-1">
<header class="flex h-12 items-center px-4">
<Sidebar.Trigger
class="hover:bg-muted focus-visible:ring-foreground inline-flex size-8 items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2"
aria-label="Toggle sidebar"
>
<SidebarIcon class="size-4" />
</Sidebar.Trigger>
</header>
</Sidebar.Inset>
{/snippet}
</Sidebar.Provider>
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: "Cal Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/CalSans-SemiBold.woff2") format("woff2");
}
:root {
/* Colors */
--background: hsl(0 0% 100%);
--background-alt: hsl(0 0% 100%);
--foreground: hsl(0 0% 9%);
--foreground-alt: hsl(0 0% 32%);
--muted: hsl(240 5% 96%);
--muted-foreground: hsla(0 0% 9% / 0.4);
--border: hsl(240 6% 10%);
--border-input: hsla(240 6% 10% / 0.17);
--border-input-hover: hsla(240 6% 10% / 0.4);
--border-card: hsla(240 6% 10% / 0.1);
--dark: hsl(240 6% 10%);
--dark-10: hsla(240 6% 10% / 0.1);
--dark-40: hsla(240 6% 10% / 0.4);
--dark-04: hsla(240 6% 10% / 0.04);
--accent: hsl(204 94% 94%);
--accent-foreground: hsl(204 80% 16%);
--destructive: hsl(347 77% 50%);
--tertiary: hsl(37.7 92.1% 50.2%);
--line: hsl(0 0% 100%);
/* black */
--contrast: hsl(0 0% 0%);
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: hsl(0 0% 5%);
--background-alt: hsl(0 0% 8%);
--foreground: hsl(0 0% 95%);
--foreground-alt: hsl(0 0% 70%);
--muted: hsl(240 4% 16%);
--muted-foreground: hsla(0 0% 100% / 0.4);
--border: hsl(0 0% 96%);
--border-input: hsla(0 0% 96% / 0.17);
--border-input-hover: hsla(0 0% 96% / 0.4);
--border-card: hsla(0 0% 96% / 0.1);
--dark: hsl(0 0% 96%);
--dark-40: hsl(0 0% 96% / 0.4);
--dark-10: hsl(0 0% 96% / 0.1);
--dark-04: hsl(0 0% 96% / 0.04);
--accent: hsl(204 90% 90%);
--accent-foreground: hsl(204 94% 94%);
--destructive: hsl(350 89% 60%);
--line: hsl(0 0% 9.02%);
--tertiary: hsl(61.3 100% 82.2%);
/* white */
--contrast: hsl(0 0% 100%);
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
@theme inline {
--color-background: var(--background);
--color-background-alt: var(--background-alt);
--color-foreground: var(--foreground);
--color-foreground-alt: var(--foreground-alt);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border-card);
--color-border-input: var(--border-input);
--color-border-input-hover: var(--border-input-hover);
--color-border-card: var(--border-card);
--color-dark: var(--dark);
--color-dark-10: var(--dark-10);
--color-dark-40: var(--dark-40);
--color-dark-04: var(--dark-04);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-tertiary: var(--tertiary);
--color-line: var(--line);
--color-contrast: var(--contrast);
--shadow-mini: var(--shadow-mini);
--shadow-mini-inset: var(--shadow-mini-inset);
--shadow-popover: var(--shadow-popover);
--shadow-kbd: var(--shadow-kbd);
--shadow-btn: var(--shadow-btn);
--shadow-card: var(--shadow-card);
--shadow-date-field-focus: var(--shadow-date-field-focus);
--text-xxs: 10px;
--radius-card: 16px;
--radius-card-lg: 20px;
--radius-card-sm: 10px;
--radius-input: 9px;
--radius-button: 5px;
--radius-5px: 5px;
--radius-9px: 9px;
--radius-10px: 10px;
--radius-15px: 15px;
--spacing-input: 3rem;
--spacing-input-sm: 2.5rem;
--breakpoint-desktop: 1440px;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-caret-blink: caret-blink 1s ease-out infinite;
--animate-scale-in: scale-in 0.2s ease;
--animate-scale-out: scale-out 0.15s ease;
--animate-fade-in: fade-in 0.2s ease;
--animate-fade-out: fade-out 0.15s ease;
--animate-enter-from-left: enter-from-left 0.2s ease;
--animate-enter-from-right: enter-from-right 0.2s ease;
--animate-exit-to-left: exit-to-left 0.2s ease;
--animate-exit-to-right: exit-to-right 0.2s ease;
--font-sans: "Inter", "sans-serif";
--font-mono: "Source Code Pro", "monospace";
--font-alt: "Courier", "sans-serif";
--font-display: "Cal Sans", "sans-serif";
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--bits-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--bits-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
@keyframes enter-from-right {
from {
opacity: 0;
transform: translateX(200px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes enter-from-left {
from {
opacity: 0;
transform: translateX(-200px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes exit-to-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(200px);
}
}
@keyframes exit-to-left {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-200px);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: rotateX(-10deg) scale(0.9);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scale-out {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(-10deg) scale(0.95);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border-card, currentColor);
}
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
scrollbar-color: var(--bg-muted);
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
::selection {
background: #fdffa4;
color: black;
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:ring-foreground focus-visible:ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1;
}
}
.link {
@apply hover:text-foreground/80 focus-visible:ring-foreground focus-visible:ring-offset-background rounded-xs focus-visible:outline-hidden inline-flex items-center gap-1 font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-offset-2;
}
}
Overview
The Sidebar primitive provides state, responsive behavior, keyboard controls, semantics, and data attributes for building application sidebars. It is intentionally unstyled: use its parts to create a reusable sidebar that belongs to your product and design system.
The component follows a compound structure familiar from shadcn's Sidebar while keeping layout, mobile presentation, styling, and persistence in your control.
Key Features
- Desktop and Mobile State: Independent
openandopenMobilevalues prevent one layout from unexpectedly changing the other. - Responsive Context: Automatically switches to mobile state at a configurable breakpoint, or accepts an explicit
isMobilevalue. - Keyboard Shortcut: Command/Control + B toggles the active sidebar by default.
- Accessible Controls:
TriggerandRailexpose the active state througharia-expandedand reference the sidebar witharia-controls. - Composable Parts: Includes containers for headers, content, groups, menus, actions, badges, nested menus, skeletons, and inset content.
- Styling Hooks: State and configuration are exposed through data attributes on each relevant part.
Structure
<script lang="ts">
import { Sidebar } from "bits-ui";
</script>
<Sidebar.Provider>
<Sidebar.Root>
<Sidebar.Header />
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel />
<Sidebar.GroupAction />
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton />
<Sidebar.MenuAction />
<Sidebar.MenuBadge />
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer />
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<Sidebar.Trigger />
<!-- page content -->
</Sidebar.Inset>
</Sidebar.Provider>
Provider must wrap every sidebar part. Beyond that requirement, parts can be arranged to fit your layout.
Managing State
open controls the desktop sidebar and defaults to true. openMobile controls the mobile sidebar and defaults to false. Both are bindable.
<script lang="ts">
import { Sidebar } from "bits-ui";
let open = $state(true);
let openMobile = $state(false);
</script>
<Sidebar.Provider bind:open bind:openMobile>
<!-- ... -->
</Sidebar.Provider>
Use onOpenChange and onOpenMobileChange when you need to react to changes. Set disabled on the provider to prevent all toggling.
Snippet State
The Provider and Root children snippets expose the current state and control functions. This is useful for conditional layout or composing a modal mobile sidebar.
<Sidebar.Provider>
{#snippet children({ state, isMobile, openMobile, setOpenMobile, toggle })}
<p>{state}</p>
<button onclick={toggle}>Toggle</button>
{#if isMobile && openMobile}
<!-- Render the sidebar in your Dialog or Sheet component. -->
<button onclick={() => setOpenMobile(false)}>Close</button>
{/if}
{/snippet}
</Sidebar.Provider>
For an accessible modal mobile experience, compose the mobile Root with the Bits UI Dialog primitive, as shown in the demo. This adds managed focus, Escape handling, and outside-interaction behavior without coupling the Sidebar primitive to a particular presentation.
Responsive Behavior
When isMobile is not provided, the provider watches the viewport and uses mobile state below mobileBreakpoint (768 pixels by default).
<Sidebar.Provider mobileBreakpoint={900}>
<!-- ... -->
</Sidebar.Provider>
Pass isMobile when your application already has a responsive-state source or when the sidebar lives in a container whose behavior does not match the viewport.
Collapse Modes and Variants
Root exposes configuration as data attributes so your styles can implement the desired layout:
collapsible="offcanvas"hides the sidebar outside the viewport when collapsed.collapsible="icon"reduces the sidebar to an icon-width rail when collapsed.collapsible="none"keeps it expanded and disablesTriggerandRailtoggling.variant="sidebar","floating", or"inset"describes the visual relationship to the page.side="left"or"right"describes its physical position.
The primitive does not set widths or transitions. This keeps values such as the expanded width, collapsed rail width, and animation curve in your CSS.
[data-sidebar-root] {
width: 16rem;
overflow: hidden;
transition: width 200ms linear;
}
[data-sidebar-root][data-collapsible="icon"][data-state="collapsed"] {
width: 3rem;
}
@media (prefers-reduced-motion: reduce) {
[data-sidebar-root] {
transition: none;
}
}
Keep sidebar content mounted during the width transition. Hide or clip labels with data-state styles instead of conditionally removing them, which avoids a second
layout change while the panel is moving.
Keyboard Shortcut
The default shortcut is Command/Control + B. Change its key with keyboardShortcut, or set it to null to disable the shortcut.
<Sidebar.Provider keyboardShortcut="s">
<!-- Command/Control + S -->
</Sidebar.Provider>
The shortcut is ignored when Shift or Alt is held, when the event has already been prevented, or when sidebar toggling is disabled.
Active Menu Items
Use isActive on MenuButton and MenuSubButton. It adds data-active and aria-current="page", which you can target in your styles.
<Sidebar.MenuButton
isActive={pathname === "/dashboard"}
class="data-[active]:bg-accent"
>
Dashboard
</Sidebar.MenuButton>
MenuButton also exposes data-size and data-variant; MenuAction exposes data-show-on-hover. These attributes describe styling intent without supplying styles.
Programmatic Access
Call useSidebar in a component rendered inside Provider to access the same state and controls without passing snippet props through intermediate components.
<script lang="ts">
import { Sidebar } from "bits-ui";
const sidebar = Sidebar.useSidebar();
</script>
<button onclick={sidebar.toggle}>
Sidebar is {sidebar.state}
</button>
Persistence
State persistence is deliberately left to the application. Bind open and store it in a cookie, local storage, or your own state layer when persistence is appropriate. The primitive does not write cookies or access storage on its own.
API Reference
Provides responsive open state and controls to every sidebar part.
| Property | Details |
|---|---|
open | |
onOpenChange | |
openMobile | |
onOpenMobileChange | |
disabled | |
isMobile | |
mobileBreakpoint | |
keyboardShortcut | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-state | |
data-mobile | |
data-disabled | |
data-sidebar-provider |
The main sidebar container.
| Property | Details |
|---|---|
side | |
variant | |
collapsible | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-state | |
data-mobile | |
data-side | |
data-variant | |
data-collapsible | |
data-sidebar-root |
A button that toggles the active desktop or mobile sidebar state.
| Property | Details |
|---|---|
disabled | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-state | |
data-mobile | |
data-disabled | |
data-sidebar-trigger |
A secondary, pointer-oriented button for toggling the sidebar from its edge.
| Property | Details |
|---|---|
disabled | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-state | |
data-mobile | |
data-disabled | |
data-sidebar-rail |
The main content container used alongside the inset sidebar variant.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-inset |
An input styled by consumers for sidebar search or filtering.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-input |
A container for persistent content at the top of the sidebar.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-header |
A container for persistent content at the bottom of the sidebar.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-footer |
A horizontal separator between sidebar regions.
| Property | Details |
|---|---|
decorative | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-separator |
The primary scrollable area between the sidebar header and footer.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-content |
Groups related sidebar navigation and actions.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-group |
A visible label for a sidebar group.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-group-label |
A button for an action associated with a sidebar group.
| Property | Details |
|---|---|
disabled | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-disabled | |
data-sidebar-group-action |
The content container for a sidebar group.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-group-content |
A semantic list of sidebar menu items.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-menu |
A semantic sidebar menu list item.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-menu-item |
A sidebar menu button with active, size, and style-variant states.
| Property | Details |
|---|---|
isActive | |
size | |
variant | |
disabled | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-active | |
data-disabled | |
data-size | |
data-variant | |
data-sidebar-menu-button |
A secondary action associated with a sidebar menu item.
| Property | Details |
|---|---|
disabled | |
showOnHover | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-disabled | |
data-show-on-hover | |
data-sidebar-menu-action |
A badge associated with a sidebar menu item.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-menu-badge |
A presentation container for a loading menu item.
| Property | Details |
|---|---|
showIcon | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-icon | |
data-sidebar-menu-skeleton |
A semantic nested list within a sidebar menu item.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-menu-sub |
A semantic item in a nested sidebar menu.
| Property | Details |
|---|---|
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-sidebar-menu-sub-item |
A link for a nested sidebar menu destination.
| Property | Details |
|---|---|
isActive | |
size | |
ref | |
children | |
child |
| Data Attribute | Details |
|---|---|
data-active | |
data-size | |
data-sidebar-menu-sub-button |