App Shell

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.

Dashboard
Main content
<!-- +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 (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.

Collapsed to an icon rail. Hover an icon for its label, or click the edge rail to expand.
<!-- 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.

All three controls at once. In practice you would pick one.
<!-- 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

PropTypeDefault
apiSidebarApi-
collapsedboolean-
openboolean-
breakpoint'sm' | 'md' | 'lg' | 'xl''lg'
...ButtonPropsButtonProps-

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.

MemberType
toggle() => void
expand() => void
collapse() => void
collapsedreadonly boolean
openreadonly boolean
belowreadonly boolean
statereadonly '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.

Content area
<!-- 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.

Collapse this one, then reload the page. It stays collapsed.
<!-- 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.

Acme Inc
<!-- 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

VariableDescription
--ui-sidebar-header-heightHeight of the header row. Falls back to --ui-header-height, then 4rem, so it lines up with a sibling Header
--ui-sidebar-footer-heightMinimum height of the footer row. Defaults to 4rem

Snippets

SnippetDescription
headerReplaces the entire default header. Receives { collapsed, state }
titleSlot / descriptionSlotCustom title and description inside the default header
actionsHeader action buttons, placed before the close button
closeSlotCustom close button in the default header
footerFooter content below the navigation, typically a user menu
childrenExtra content inside the scrollable body, after the navigation
itemCustom navigation entry renderer, forwarded to the internal NavigationMenu
toggleSlotReplaces the default footer collapse button
railSlotReplaces the default edge rail

UI Slots

Use the ui prop to override classes.

SlotDescription
rootThe aside element
header / headerContent / title / description / headerActionsThe header row and its parts
contentThe scrollable body holding the navigation
footerThe footer row
toggleThe footer collapse button
rail / railHandleThe interactive edge rail and its visible handle

Props

PropTypeDefault
itemsNavigationMenuItem[] | NavigationMenuItem[][]-
menuPartial<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'
apiSidebarApi-
collapsedbooleanfalse
openbooleanfalse
onCollapse(collapsed: boolean) => void-
onOpenChange(open: boolean) => void-
titlestring-
descriptionstring-
closeboolean | ButtonPropsfalse
closeIconstring-
railbooleanfalse
toggleboolean | ButtonPropsfalse
widthnumber256
collapsedWidthnumber64
persistboolean | { key }false
autoClosebooleantrue
transitionbooleantrue
asstring'aside'
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-