SidebarLayout
App-shell layout for a sidebar that stays on desktop and becomes a hamburger overlay on mobile. It wraps the Sidebar primitive, centers the main column, and renders an optional mobile header.
Overview
The documentation site you are reading is itself wrapped in SidebarLayout. Below 1024px the mobile header appears,
the sidebar becomes a slide-in overlay with a backdrop, and the main column reflows to full
width.
For non-shell sidebars (right-side detail panels, drawers inside a page), use the Sidebar primitive directly.
Playground
Dashboard
The main column is inset by --sidebar-effective-width, so the content never
sits underneath the rail. Below 1024px the rail becomes an overlay and this column reflows
to full width.
<script lang="ts">
import { SidebarLayout } from '@urbicon-ui/blocks';
let open = $state(false);
const NAV = ['Dashboard', 'Projects', 'Team', 'Settings'];
</script>
<SidebarLayout
bind:open
sidebarWidth="11rem"
>
{#snippet sidebarHeader()}
<div class="text-text-primary px-4 py-3 text-sm font-semibold">Acme</div>
{/snippet}
{#snippet sidebar()}
<nav aria-label="Demo sidebar" class="space-y-1 p-3">
{#each NAV as item, i (item)}
<a
href="#{item.toLowerCase()}"
class={[
'block rounded-lg px-3 py-2 text-sm transition-colors',
i === 0
? 'bg-surface-subtle text-text-primary font-medium'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'
]}
>
{item}
</a>
{/each}
</nav>
{/snippet}
{#snippet mobileHeader({ openSidebar })}
<button
class="text-text-secondary hover:text-text-primary -m-2 p-2 transition-colors"
onclick={openSidebar}
aria-label="Open navigation"
>
<MenuIcon class="h-5 w-5" />
</button>
<span class="text-text-primary text-sm font-semibold">Acme</span>
{/snippet}
<p class="text-text-primary text-lg font-semibold">Dashboard</p>
<p class="text-text-secondary mt-2 text-sm leading-relaxed">
The main column is inset by <code>--sidebar-effective-width</code>, so the content never
sits underneath the rail. Below 1024px the rail becomes an overlay and this column reflows
to full width.
</p>
<!-- `collapsible` blendet die Leiste auf allen Viewports aus; ohne einen
Öffner im Inhalt wäre der Modus in der Vorschau eine Sackgasse. -->
{#if values.mode === 'collapsible' && !open}
<button class="text-primary mt-4 text-sm hover:underline" onclick={() => (open = true)}>
Show sidebar
</button>
{/if}
</SidebarLayout>01 When to use
SidebarLayout is an app shell: it wires a Sidebar, a centered main
column, and an optional mobile header into a responsive layout. Use it when you want a permanent
sidebar on desktop with a hamburger overlay on mobile and you don't want to write the
surrounding grid yourself. The component exposes --sidebar-width and --sidebar-effective-width on its root so the main-content
offset animates in lockstep with the sidebar.
Pick a different layout or overlay if you need:
- A custom outer grid (multi-region layout, header bar with brand controls, full bleed sections) → Sidebar primitive directly.
- A transient detail panel that pulls focus (backdrop + focus-trap) → Drawer.
- A floating panel anchored to a specific element → Popover.
See the Dashboard recipe for a full app-shell demonstration.
02 Examples
Default app shell
<script>
import { Button, MenuIcon, SidebarLayout, ThemeSwitcher } from '@urbicon-ui/blocks';
let sidebarOpen = $state(false);
</script>
<SidebarLayout bind:open={sidebarOpen} sidebarWidth="16rem">
{#snippet sidebarHeader()}
<a href="/" class="flex h-14 items-center font-semibold">My App</a>
{/snippet}
{#snippet sidebar()}
<nav aria-label="Demo sidebar" class="flex flex-col gap-1 p-3">
<a href="/dashboard" class="rounded-lg px-3 py-2 text-sm">Dashboard</a>
<a href="/projects" class="rounded-lg px-3 py-2 text-sm">Projects</a>
<a href="/settings" class="rounded-lg px-3 py-2 text-sm">Settings</a>
</nav>
{/snippet}
{#snippet sidebarFooter()}
<div class="flex items-center justify-between p-3">
<span class="text-text-tertiary text-xs">v1.0.0</span>
<ThemeSwitcher size="xs" />
</div>
{/snippet}
{#snippet mobileHeader({ openSidebar })}
<Button variant="ghost" size="sm" onclick={openSidebar} aria-label="Open menu">
<MenuIcon class="h-5 w-5" />
</Button>
<span class="font-semibold">My App</span>
{/snippet}
<h1 class="text-2xl font-bold">Page content</h1>
<p class="text-text-secondary mt-2">Goes inside a centered, max-width column.</p>
</SidebarLayout>Grouped navigation with active state
<script>
import { page } from '$app/state';
import { SidebarLayout, Button, MenuIcon, HomeIcon, UsersIcon, SettingsIcon } from '@urbicon-ui/blocks';
const sections = [
{ label: 'Overview', items: [{ href: '/', label: 'Dashboard', icon: HomeIcon }] },
{
label: 'Workspace',
items: [
{ href: '/team', label: 'Team', icon: UsersIcon },
{ href: '/settings', label: 'Settings', icon: SettingsIcon }
]
}
];
let sidebarOpen = $state(false);
const path = $derived(page.url.pathname);
const isActive = (href) => href === '/' ? path === '/' : path === href || path.startsWith(`${href}/`);
const activeItem = $derived(
sections.flatMap((s) => s.items)
.sort((a, b) => b.href.length - a.href.length)
.find((i) => isActive(i.href))
);
</script>
<SidebarLayout bind:open={sidebarOpen} sidebarWidth="17rem">
{#snippet sidebarHeader()}
<a href="/" class="flex h-14 items-center font-semibold">My App</a>
{/snippet}
{#snippet sidebar()}
<nav aria-label="Demo sidebar" class="flex flex-col gap-6 p-3">
{#each sections as section, i (section.label ?? i)}
<div class="flex flex-col gap-1">
<span class="text-text-tertiary px-3 pb-1 text-2xs font-semibold uppercase tracking-wider">
{section.label}
</span>
{#each section.items as item (item.href)}
{@const Icon = item.icon}
<a href={item.href} class={isActive(item.href)
? 'bg-primary-subtle text-primary flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium'
: 'text-text-secondary hover:bg-surface-hover flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm'}>
<Icon class="h-4 w-4 shrink-0" />
<span class="truncate">{item.label}</span>
</a>
{/each}
</div>
{/each}
</nav>
{/snippet}
{#snippet mobileHeader({ openSidebar })}
<Button variant="ghost" size="sm" onclick={openSidebar} aria-label="Open menu">
<MenuIcon class="h-5 w-5" />
</Button>
{#if activeItem}
<span class="font-semibold truncate">{activeItem.label}</span>
{/if}
{/snippet}
<!-- page content -->
</SidebarLayout>Collapsible mode, with the toggle where the layout owns the seam
<script>
import { SidebarLayout, Button, MenuIcon, createPersistentState } from '@urbicon-ui/blocks';
const railOpen = createPersistentState({ key: 'sidebar', defaultValue: true });
</script>
<SidebarLayout bind:open={railOpen.value} mode="collapsible" sidebarWidth="16rem">
{#snippet sidebarHeader()}<span class="font-semibold">App</span>{/snippet}
{#snippet sidebar()}<nav class="p-3"><!-- … --></nav>{/snippet}
{#snippet toggle(rail)}
<Button
variant="ghost"
size="sm"
{...rail.triggerProps}
onclick={rail.toggle}
aria-label={rail.open ? 'Collapse sidebar' : 'Expand sidebar'}
>
<MenuIcon class="h-5 w-5" />
</Button>
{/snippet}
<!-- page content -->
</SidebarLayout>Right-side rail
<SidebarLayout side="right" sidebarWidth="20rem">
{#snippet sidebar()}
<div class="p-4">Inspector content…</div>
{/snippet}
<!-- main content (occupies left side) -->
</SidebarLayout>03 Customization
Branded shell via slotClasses
<SidebarLayout
bind:open={sidebarOpen}
sidebarWidth="16rem"
slotClasses={{
root: 'bg-neutral-50',
sidebarPanel: 'bg-neutral-900 border-neutral-800',
sidebarHeader: 'border-neutral-800',
sidebarFooter: 'border-neutral-800',
mobileHeader: 'bg-neutral-900 text-white border-neutral-800'
}}
>
<!-- snippets -->
</SidebarLayout>Reusable preset via BlocksProvider
<BlocksProvider
presets={{
SidebarLayout: {
brand: {
slotClasses: {
sidebarPanel: 'bg-neutral-900 border-neutral-800',
mobileHeader: 'bg-neutral-900 text-white'
}
}
}
}}
>
<SidebarLayout preset="brand" bind:open={sidebarOpen}>
<!-- … -->
</SidebarLayout>
</BlocksProvider>Wider content column
<SidebarLayout contentMaxWidth="2xl" bind:open={sidebarOpen}>
<!-- content uses max-w-screen-2xl -->
</SidebarLayout>
<SidebarLayout contentMaxWidth="none" bind:open={sidebarOpen}>
<!-- content stretches to fill the available width -->
</SidebarLayout>04 Accessibility
Skip-link target
The main column is rendered as <main id="main-content">, so a global skip-link with href="#main-content" jumps straight to the page content.
Sidebar landmark
Inherits the Sidebar primitive's behavior: rendered
as <aside> and marked aria-hidden="true" while the mobile overlay is closed.
Toggle wiring
The toggle snippet receives triggerProps — id, aria-expanded and aria-controls pointing at the sidebar panel. Spread
it onto your control and the announcement is correct in both states. Each of the two render
sites gets its own id, so the rail and header copies
never collide.
Mobile overlay
Body scroll is locked while the overlay is open. Pressing Escape closes the overlay (configurable via closeOnEscape), and a backdrop click also dismisses
it (configurable via closeOnBackdropClick).
Reduced motion
The padding-transition on the main column uses the design system's --blocks-duration-normal and --blocks-ease-confident tokens, which respect prefers-reduced-motion.
05 API Reference
19 propsProp | Type | Default | Description | |
|---|---|---|---|---|
children | Snippet | — | Page content rendered inside the centered main column. | |
class | string | — | Additional CSS classes applied to the root wrapper. | |
closeOnBackdropClick | boolean | true | Close the mobile sidebar overlay when clicking the backdrop. | |
closeOnEscape | boolean | true | Close the mobile sidebar overlay when pressing Escape. | |
contentMaxWidth | SidebarLayoutVariants['contentMaxWidth'] | 'xl' | Maximum width of the centered content column. | |
mobileHeader | Snippet<[MobileHeaderContext]> | — | Mobile header bar, hidden on desktop in responsive mode. Receives a
helper to open the sidebar so a hamburger button needs no extra wiring.
If omitted, no mobile header is rendered — unless toggle is given, which
needs the header bar as its mobile seam.
With toggle, the header already carries the sidebar control: leave your
own hamburger out, or the bar shows two of them. | |
mode | responsivecollapsible | 'responsive' | Sidebar mode.
- responsive (default): permanent on desktop (≥1024px), slide-in overlay on mobile.
- collapsible: toggleable at all viewports — width animation on desktop, overlay on mobile. | |
onOpenChange | (open: boolean) => void | — | Fires when the sidebar open state changes. | |
open | boolean | false | Sidebar visibility. In responsive mode this only affects the mobile
overlay. In collapsible mode it controls visibility at all viewports.
Supports bind:open. | |
preset | string | — | Apply a named preset registered via
<BlocksProvider presets={{ SidebarLayout: {...} }}>. Use this to share
a branded shell look across the app instead of repeating class overrides. | |
side | SidebarLayoutVariants['side'] | 'left' | Which edge the sidebar attaches to. | |
sidebar | Snippet | — | Sidebar main content — typically a <nav>. | |
sidebarFooter | Snippet | — | Sidebar footer (below the scrollable nav). | |
sidebarHeader | Snippet | — | Sidebar header (above the scrollable nav). | |
sidebarWidth | string | '16rem' | Sidebar panel width. Single source of truth — the layout exposes it as
--sidebar-width (constant) and --sidebar-effective-width (animates to
0 when collapsed) on the layout root, so the main content offset stays
in sync automatically. | |
slotClasses | Partial<Record<SidebarLayoutSlot, string>> | — | Per-slot class overrides. A sidebar-prefixed key is forwarded to the
embedded <Sidebar>'s slot of that name — sidebarPanel reaches its
panel, sidebarBackdrop its backdrop, and so on. | |
toggle | Snippet<[SidebarToggleContext]> | — | The control that opens and closes the sidebar, rendered by the layout at
the seams it owns: the rail edge on desktop (mode="collapsible" only —
a responsive sidebar is permanent there and open would toggle nothing)
and the header bar on mobile. One snippet, both places, each render with
its own triggerId.
The desktop grip floats over the content column, so while it renders the
layout widens the content offset by --sidebar-toggle-gutter, which
defaults to 3.5rem — room for an icon button. A wider control needs a
wider strip: set the property on the layout root, e.g.
class="[--sidebar-toggle-gutter:5rem]".
Persistence is deliberately not a prop: createPersistentState plus
bind:open is the two-line version and keeps one storage story in the app. | |
unstyled | boolean | — | Strip all default styles. Combine with slotClasses for a custom layout. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
MobileHeaderContext | interface | helper | 0 | Snippet payload for the mobileHeader slot. Receives an opener for the
sidebar overlay so the consumer can wire a hamburger button without
threading state through the layout. | |
SidebarToggleContext | interface | helper | 0 | Snippet payload for the toggle slot — Collapsible's trigger vocabulary
(open, toggle, triggerId, contentId) plus the ready-made attribute
record from useDisclosure.
The layout renders the snippet at both seams it owns (the rail edge on
desktop, the header on mobile) and hands each render its **own**
triggerId, so spreading triggerProps on both cannot produce a duplicate
id. contentId is the sidebar panel and is the same for both. | |
SidebarForwardKey | type | helper | 0 | The slotClasses keys forwarded to the embedded <Sidebar> — one per slot
it declares, under a sidebar prefix.
Exported because SidebarLayout.svelte *builds* the key it reads and
annotates it with this type. That is what writes the prefix once for both
halves: mistyping it in the builder is a compile error, where the resolved
record it indexes is a Record<string, string> that would otherwise accept
any string and quietly return nothing (measured). | |
SidebarLayoutProps | interface | props | 0 | — | |
SidebarSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
DisclosureTriggerProps | interface | props | 0 | Attributes for the control that opens and closes the region. | |
SidebarLayoutSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
SidebarLayoutVariants | type | variant | 0 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 0 | — |
07 Installation
Import
import { SidebarLayout } from '@urbicon-ui/blocks';