SidebarLayout
App-shell layout for permanent-on-desktop / overlay-on-mobile sidebars. Wraps the Sidebar primitive, exposes --sidebar-width on the layout root so the main content offset works without boilerplate, and renders an optional mobile header with a hamburger opener.
Overview
The documentation site you are reading now is itself wrapped in SidebarLayout. Resize your viewport below 1024px to see
the mobile hamburger header in action — 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
<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 offsets itself against <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 the ready-made 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) — build it yourself with the 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 (toggle on all viewports)
<SidebarLayout
bind:open={sidebarOpen}
mode="collapsible"
sidebarWidth="16rem"
>
{#snippet sidebarHeader()}<span class="font-semibold">App</span>{/snippet}
{#snippet sidebar()}<nav class="p-3"><!-- … --></nav>{/snippet}
<Button onclick={() => (sidebarOpen = !sidebarOpen)}>
{sidebarOpen ? 'Collapse' : 'Expand'} sidebar
</Button>
</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',
sidebar: '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: {
sidebar: '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.
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
Prop | 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. | |
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. sidebar* slots are forwarded to the embedded
<Sidebar> component (mapped to its slotClasses.panel/header/...). | |
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. | |
SidebarLayoutProps | interface | props | 0 | — | |
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';