Sidebar
A collapsible dashboard sidebar built on a vertical NavigationMenu. It collapses to an icon rail or off-screen, remembers that choice, and turns into an overlay below its breakpoint.
The sidebar is h-svh and
sticky, so it fills the viewport in a real layout. The demos below are boxed to a fixed height
with ui={{ root: 'h-full' }} and use breakpoint="sm" so they
stay visible on this page. Below 640px those demos are in overlay mode and show only their
content area, which is the component behaving correctly rather than an empty box. The Mobile
Overlay section further down is the one to read on a phone.
Basic Usage
The sidebar is a flex child, so place it next to your content. Pass items and it renders the navigation for you.
<script lang="ts">
import { Sidebar } from 'sv5ui';
import type { NavigationMenuItem } from 'sv5ui';
const items: NavigationMenuItem[] = [
{ label: 'Dashboard', icon: 'lucide:layout-dashboard', href: '/' },
{ label: 'Projects', icon: 'lucide:folder', href: '/projects' },
{ label: 'Team', icon: 'lucide:users', href: '/team' },
{ label: 'Settings', icon: 'lucide:settings', href: '/settings' }
];
</script>
<!-- The sidebar is a flex child, so put it next to your content -->
<div class="flex">
<Sidebar title="Acme Inc" {items} />
<main class="flex-1 p-6">
<slot />
</main>
</div>Dashboard Layout
The shape you will reach for most: a sidebar beside a column holding a Header and Main.
The sidebar header row reads --ui-sidebar-header-height, falling back to --ui-header-height, so it aligns with the Header without any extra work.
<!-- +layout.svelte: the canonical dashboard shell -->
<script lang="ts">
import { Sidebar, SidebarTrigger, Header, Main } from 'sv5ui';
import type { SidebarApi } from 'sv5ui';
let api = $state<SidebarApi>();
let { children } = $props();
</script>
<div class="flex min-h-svh">
<Sidebar bind:api title="Acme Inc" {items} persist rail />
<div class="flex min-w-0 flex-1 flex-col">
<Header>
{#snippet left()}
<SidebarTrigger {api} />
{/snippet}
</Header>
<Main>{@render children()}</Main>
</div>
</div>
<!-- The header row of the sidebar is
--ui-sidebar-header-height tall, falling back to
--ui-header-height and then 4rem, so it lines up with
the Header next to it without any extra work. -->Variants
sidebar sits flush against the edge with a divider, floating detaches into a rounded elevated panel, and inset goes
transparent for layouts where the content area is the card.
sidebar
floating
inset
<!-- sidebar (default): flush to the edge with a divider border -->
<Sidebar variant="sidebar" {items} />
<!-- floating: a detached, rounded, elevated panel -->
<Sidebar variant="floating" {items} />
<!-- inset: transparent, for an inset dashboard layout where the
content area is the card and the sidebar is the background -->
<Sidebar variant="inset" {items} />Collapse Behaviour
icon,
the default, leaves an icon rail where labels return as tooltips and groups open as popover
flyouts. offcanvas slides it fully off-screen. none pins
it open and drops the rail, close and toggle controls entirely.
<!-- icon (default): collapses to an icon rail. Labels come back
as tooltips and groups open as popover flyouts. -->
<Sidebar collapsible="icon" collapsedWidth={64} {items} />
<!-- offcanvas: collapses fully off-screen -->
<Sidebar collapsible="offcanvas" {items} />
<!-- none: cannot be collapsed. rail, close and toggle
are not rendered at all, so do not bother passing them. -->
<Sidebar collapsible="none" {items} />
<!-- collapsed is bindable. Bind it rather than passing a literal,
otherwise the rail and toggle mutate a value you own. -->
<Sidebar bind:collapsed collapsible="icon" rail {items} />Collapse Controls
None of the built-in controls are on by default, so you pick the one that fits: rail on the edge, close in the header, or toggle in
the footer.
<!-- Three opt-in ways to collapse, none of them on by default -->
<!-- rail: a draggable-looking edge strip, click to toggle -->
<Sidebar rail {items} />
<!-- close: a button in the header -->
<Sidebar close {items} closeIcon="lucide:panel-left-close" />
<!-- toggle: a button pinned to the bottom of the footer -->
<Sidebar toggle={{ label: 'Collapse', variant: 'ghost' }} {items} />
<!-- Or drive it from anywhere with the api -->
<Button label="Toggle" onclick={() => api?.toggle()} />SidebarTrigger
A header button that does the right thing at both sizes: it collapses the sidebar on desktop
and opens the mobile menu below the breakpoint. Pass the sidebar's api and nothing else needs repeating, since the api already carries the breakpoint. It extends ButtonProps, so style it like any button.
state: expanded, below breakpoint: false
<script lang="ts">
import { Sidebar, SidebarTrigger } from 'sv5ui';
import type { SidebarApi } from 'sv5ui';
let api = $state<SidebarApi>();
</script>
<!-- Preferred: pass the api. It already knows the breakpoint,
so the trigger collapses the rail on desktop and opens the
mobile menu on small screens with nothing repeated. -->
<Sidebar bind:api breakpoint="lg" {items} />
<SidebarTrigger {api} />
<!-- Without an api, bind the two states and repeat the breakpoint -->
<Sidebar bind:collapsed bind:open breakpoint="lg" {items} />
<SidebarTrigger bind:collapsed bind:open breakpoint="lg" />
<!-- SidebarTrigger extends ButtonProps, so style it like any button -->
<SidebarTrigger {api} variant="ghost" size="sm" icon="lucide:menu" />SidebarTrigger Props
| Prop | Type | Default |
|---|---|---|
api | SidebarApi | - |
collapsed | boolean | - |
open | boolean | - |
breakpoint | 'sm' | 'md' | 'lg' | 'xl' | 'lg' |
...ButtonProps | ButtonProps | - |
Imperative API
Bind api to
control the sidebar from anywhere in the layout. Its methods are viewport aware: below the breakpoint
they act on the mobile menu, above it on the desktop rail.
collapsed: false, open: false, state: expanded
<script lang="ts">
let api = $state<SidebarApi>();
</script>
<Sidebar bind:api {items} />
<!-- toggle / expand / collapse act on the mobile menu below
breakpoint and on the desktop rail above it -->
<Button label="Toggle" onclick={() => api?.toggle()} />
<Button label="Expand" onclick={() => api?.expand()} />
<p>
collapsed: {api?.collapsed}
open: {api?.open}
below breakpoint: {api?.below}
state: {api?.state}
</p>
<!-- bind:collapsed and bind:open still work alongside it -->
<Sidebar bind:api bind:collapsed bind:open {items} />SidebarApi
Methods and readonly state exposed via bind:api.
| Member | Type |
|---|---|
toggle | () => void |
expand | () => void |
collapse | () => void |
collapsed | readonly boolean |
open | readonly boolean |
below | readonly boolean |
state | readonly 'expanded' | 'collapsed' |
Navigation Items
Items are NavigationMenu entries, so labels, badges, avatars, chips and nested children all work. Pass an array of
arrays for separated groups, and use menu to forward
anything else to the internal menu.
<!-- An array of arrays renders separated groups -->
const items: NavigationMenuItem[][] = [
[
{ label: 'Workspace', type: 'label' },
{ label: 'Dashboard', icon: 'lucide:layout-dashboard', href: '/' },
{
label: 'Projects',
icon: 'lucide:folder',
defaultOpen: true,
children: [
{ label: 'Active', href: '/projects/active' },
{ label: 'Archived', href: '/projects/archived' }
]
}
],
[
{ label: 'Settings', icon: 'lucide:settings', href: '/settings' },
{ label: 'Help', icon: 'lucide:circle-help', href: '/help' }
]
];
<Sidebar {items} />
<!-- menu forwards anything to the internal NavigationMenu and
overrides the defaults Sidebar sets -->
<Sidebar
{items}
menu={{ variant: 'link', color: 'success', highlight: true, exact: false, type: 'single' }}
/>Header and Footer
The header snippet replaces the default title row, typically with a brand mark or team switcher, and footer is
where the user menu goes. Both receive the collapsed state so you can shrink their contents on
the rail.
<!-- header replaces the whole default header, typically with
a brand mark or a team switcher. Every snippet receives
the collapsed state so you can shrink its content. -->
<Sidebar {items} bind:api>
{#snippet header({ collapsed })}
<div class="flex h-full items-center gap-2 px-3">
<Logo class="size-7" />
{#if !collapsed}<span class="font-semibold">Acme Inc</span>{/if}
</div>
{/snippet}
{#snippet footer({ collapsed })}
<DropdownMenu items={userMenu}>
{#snippet trigger({ props })}
<button {...props} class="flex items-center gap-2">
<Avatar src="/avatars/ana.jpg" alt="Ana" size="sm" />
{#if !collapsed}<span class="text-sm">Ana Rivera</span>{/if}
</button>
{/snippet}
</DropdownMenu>
{/snippet}
<!-- children renders inside the scrollable body, after the nav -->
{#snippet children({ collapsed })}
{#if !collapsed}
<UpgradeCard class="mt-auto" />
{/if}
{/snippet}
</Sidebar>Sizing and Persistence
Widths are plain pixel numbers. Set persist to remember the collapsed state in localStorage under the key sidebar,
or pass your own key when a page has more than one sidebar.
<!-- Widths are numbers in pixels -->
<Sidebar width={288} collapsedWidth={56} {items} />
<!-- Persist the collapsed state across reloads.
true uses the localStorage key 'sidebar'. -->
<Sidebar persist {items} />
<Sidebar persist={{ key: 'admin-sidebar' }} {items} />
<!-- Turn off the collapse and group animations -->
<Sidebar transition={false} {items} />Mobile Overlay
Below breakpoint the sidebar hides and becomes an overlay that slides in from side.
Choose between a Slideover and a Drawer with mode. It
closes itself on navigation unless you turn autoClose off.
This one uses breakpoint="xl" and mode="drawer", so below 1280px it is already an overlay and the app bar below is all that renders.
The panel is portalled, so it covers the page rather than this frame.
<!-- Below breakpoint the sidebar hides and becomes an overlay
that slides in from side. -->
<Sidebar breakpoint="lg" mode="slideover" {items} />
<!-- Or use the Drawer, with its grab handle and snap behaviour -->
<Sidebar breakpoint="md" mode="drawer" {items} />
<!-- The overlay closes itself on navigation. Turn that off if
your routes render inside the same shell. -->
<Sidebar autoClose={false} {items} />
<!-- Below the breakpoint the sidebar renders nothing inline, so the
trigger has to live somewhere that is always visible. The panel
is portalled and covers the viewport, not its parent. -->
<div class="flex h-14 items-center gap-3 border-b px-3">
<SidebarTrigger api={mobileApi} variant="ghost" color="surface" size="sm" />
<span class="text-sm font-medium">Acme Inc</span>
</div>
<div class="flex">
<Sidebar bind:api={mobileApi} title="Acme Inc" {items} breakpoint="xl" mode="drawer" />
<main class="flex-1">...</main>
</div>Right Side
side="right" moves the sidebar to the end of the row and flips the rail, the border and the mobile
overlay with it. The root gets order-last, so you can keep it first in your markup.
<!-- side="right" moves the sidebar to the end of the row.
The rail, the border and the mobile overlay all follow. -->
<div class="flex">
<main class="flex-1">...</main>
<Sidebar side="right" {items} />
</div>
<!-- Note the DOM order does not have to change: the root gets
order-last, so you can keep the Sidebar first in markup. -->Styling
Two CSS variables control the fixed rows, and everything else goes through ui.
/* The header row height, so the sidebar lines up with a
sibling Header. Falls back to --ui-header-height, then 4rem. */
--ui-sidebar-header-height
/* Minimum height of the footer row. Defaults to 4rem. */
--ui-sidebar-footer-height
/* Set them once on :root, or per instance */
<Sidebar {items} style="--ui-sidebar-header-height: 3.5rem" />
/* Everything else goes through ui: */
<Sidebar
{items}
ui={{
root: 'bg-surface-container-low',
header: 'border-b-0',
content: 'p-2'
}}
/>CSS Variables
| Variable | Description |
|---|---|
--ui-sidebar-header-height | Height of the header row. Falls back to --ui-header-height, then 4rem, so it lines up with a sibling Header |
--ui-sidebar-footer-height | Minimum height of the footer row. Defaults to 4rem |
Snippets
| Snippet | Description |
|---|---|
header | Replaces the entire default header. Receives { collapsed, state } |
titleSlot / descriptionSlot | Custom title and description inside the default header |
actions | Header action buttons, placed before the close button |
closeSlot | Custom close button in the default header |
footer | Footer content below the navigation, typically a user menu |
children | Extra content inside the scrollable body, after the navigation |
item | Custom navigation entry renderer, forwarded to the internal NavigationMenu |
toggleSlot | Replaces the default footer collapse button |
railSlot | Replaces the default edge rail |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root | The aside element |
header / headerContent / title / description / headerActions | The header row and its parts |
content | The scrollable body holding the navigation |
footer | The footer row |
toggle | The footer collapse button |
rail / railHandle | The interactive edge rail and its visible handle |
Props
| Prop | Type | Default |
|---|---|---|
items | NavigationMenuItem[] | NavigationMenuItem[][] | - |
menu | Partial<NavigationMenuProps> | - |
variant | 'sidebar' | 'floating' | 'inset' | 'sidebar' |
side | 'left' | 'right' | 'left' |
collapsible | 'icon' | 'offcanvas' | 'none' | 'icon' |
breakpoint | 'sm' | 'md' | 'lg' | 'xl' | 'lg' |
mode | 'slideover' | 'drawer' | 'slideover' |
api | SidebarApi | - |
collapsed | boolean | false |
open | boolean | false |
onCollapse | (collapsed: boolean) => void | - |
onOpenChange | (open: boolean) => void | - |
title | string | - |
description | string | - |
close | boolean | ButtonProps | false |
closeIcon | string | - |
rail | boolean | false |
toggle | boolean | ButtonProps | false |
width | number | 256 |
collapsedWidth | number | 64 |
persist | boolean | { key } | false |
autoClose | boolean | true |
transition | boolean | true |
as | string | 'aside' |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |