Skip to main content
Urbicon UI
source

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

Acme

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.

Side
Mode
<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

Permanent sidebar on desktop, hamburger overlay on mobile. The layout sets --sidebar-width and --sidebar-effective-width on its root, so the main-content offset stays in sync with the sidebar.
<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

Sections, icons, and an active-link pattern using SvelteKit's page state. The mobile header shows the current page title for context.
<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

mode='collapsible' makes the sidebar toggleable on desktop too, and the toggle snippet is the control for it. The layout renders that one snippet twice — on the rail edge above 1024px, in the header bar below it — and gives each render its own triggerId, so the two never write the same DOM id. createPersistentState keeps the choice across reloads; persistence is deliberately not a prop.
<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

Set side='right' for an inspector-style layout. The main content gets right-padding instead of left.
<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

slotClasses lets you restyle each region. sidebar* keys are forwarded into the embedded Sidebar primitive.
<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

When the same shell look repeats across an app, register it once as a preset and reference it by name. Presets win over class overrides for cohesive hover/active/dark-mode behavior.
<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

Override the centered column width via contentMaxWidth, or pass 'none' to disable centering entirely.
<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 triggerPropsid, 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 props
19 props
Prop
Type
Default
Description

06 Types

Local type definitions used by this component.

10 types
Name
Kind
Category
Used by
Description

07 Installation

Import

import { SidebarLayout } from '@urbicon-ui/blocks';