Skip to main content
Urbicon UI
source

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

Acme

Dashboard

The main column offsets itself against --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 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

The most common pattern — permanent sidebar on desktop, hamburger overlay on mobile. The layout exposes --sidebar-width and --sidebar-effective-width on its root, so the main content offset works without any CSS plumbing in the consumer.
<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 store. The mobile header surfaces 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 (toggle on all viewports)

Set mode='collapsible' to make the sidebar toggleable on desktop too. The panel animates its width via --sidebar-effective-width and the main content offset transitions in lockstep.
<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

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',
    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

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: {
          sidebar: '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.

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

18 props
18 props
Prop
Type
Default
Description

06 Types

Local type definitions used by this component.

6 types
Name
Kind
Category
Used by
Description

07 Installation

Import

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