App shell block

A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.

A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.

About this example

A copy-paste application shell built from the SvGrid UI kit in Svelte 5: an SvNavPane sidebar with sections and badges that collapses to an icon rail, a top bar with SvBreadcrumb, a search SvTextInput, a notifications button and an account SvMenu with an avatar, and a routed content area where selecting a nav item swaps the page, shown with SvStat tiles and an SvEmptyState placeholder.

App shell block - a full application layout (shadcn sidebar-01 style): a SvNavPane sidebar with sections + badges, a top bar with breadcrumb, search and account menu, and a routed content area. Selecting a nav item swaps the page. Collapse the rail with the toggle. Pure UI-kit, --sg-* themed.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How does navigation swap the content?

active is bound to the nav pane's value; the content area renders the page for that id. In a SvelteKit app, replace the switch with routes and keep the shell in the layout.

How is the sidebar collapsed?

collapsed on SvNavPane narrows it to icons with tooltips; the toggle in the top bar flips it, and the content area takes the freed width.

Where does the account menu come from?

SvMenu with an items array wrapped around the avatar button, giving profile, settings and sign-out entries with keyboard support.

Related documentation

Source code (424-block-app-shell.svelte)

<script lang="ts">
  /**
   * App shell block - a full application layout (shadcn sidebar-01 style): a
   * SvNavPane sidebar with sections + badges, a top bar with breadcrumb, search
   * and account menu, and a routed content area. Selecting a nav item swaps the
   * page. Collapse the rail with the toggle. Pure UI-kit, --sg-* themed.
   */
  import {
    SvNavPane, SvBreadcrumb, SvTextInput, SvButton, SvAvatar, SvBadge,
    SvMenu, SvCard, SvStat, SvEmptyState, SvToaster, toast,
    type NavSection, type MenuItem,
  } from '@svgrid/grid'

  const sections: NavSection[] = [
    { id: 'main', label: 'Workspace', items: [
      { id: 'dashboard', label: 'Dashboard' },
      { id: 'analytics', label: 'Analytics' },
      { id: 'orders', label: 'Orders', badge: 12 },
      { id: 'customers', label: 'Customers' },
    ] },
    { id: 'manage', label: 'Manage', items: [
      { id: 'reports', label: 'Reports' },
      { id: 'billing', label: 'Billing', badge: 'Due' },
      { id: 'team', label: 'Team' },
      { id: 'settings', label: 'Settings' },
    ] },
  ]

  const labelOf: Record<string, string> = {
    dashboard: 'Dashboard', analytics: 'Analytics', orders: 'Orders', customers: 'Customers',
    reports: 'Reports', billing: 'Billing', team: 'Team', settings: 'Settings',
  }

  let active = $state('dashboard')
  // Start collapsed on a phone: the <= 720px rule below already gives the
  // sidebar the 64px rail width, so an expanded nav pane there could only
  // paint its labels over the page. The toggle still works either way.
  let collapsed = $state(
    typeof window !== 'undefined' && window.matchMedia('(max-width: 720px)').matches,
  )
  let query = $state('')

  const accountMenu: MenuItem[] = [
    { label: 'Profile', onSelect: () => toast('Profile') },
    { label: 'Preferences', onSelect: () => toast('Preferences') },
    { separator: true },
    { label: 'Sign out', onSelect: () => toast('Signed out') },
  ]
</script>

<div class="shell" class:collapsed>
  <!-- Sidebar -->
  <div class="side">
    <div class="brand">
      <span class="logo">◧</span>
      {#if !collapsed}<strong>Northwind</strong>{/if}
    </div>
    <SvNavPane
      {sections}
      bind:value={active}
      {collapsed}
      onSelect={(id) => (active = id)}
    />
  </div>

  <!-- Main column -->
  <div class="main">
    <header class="bar">
      <button class="icon-btn" onclick={() => (collapsed = !collapsed)} aria-label="Toggle sidebar">☰</button>
      <SvBreadcrumb items={[{ label: 'Home', href: '#' }, { label: 'Workspace', href: '#' }, { label: labelOf[active] }]} />
      <div class="spacer"></div>
      <div class="search">
        <SvTextInput bind:value={query} placeholder="Search..." clearable block>
          {#snippet leading()}<span class="ic">🔎</span>{/snippet}
        </SvTextInput>
      </div>
      <SvButton variant="ghost" size="sm" ariaLabel="Notifications" onclick={() => toast('2 new notifications')}>🔔</SvButton>
      <SvMenu items={accountMenu}>
        {#snippet anchor()}<button class="acct"><SvAvatar name="Ada Lovelace" size="sm" status="online" /></button>{/snippet}
      </SvMenu>
    </header>

    <main class="content">
      <div class="page-head">
        <h2>{labelOf[active]}</h2>
        <SvBadge variant="accent" pill>Live</SvBadge>
      </div>

      {#if active === 'dashboard' || active === 'analytics'}
        <div class="cards">
          <SvCard><SvStat label="Revenue" value="$48.1k" delta={12} hint="vs last month" /></SvCard>
          <SvCard><SvStat label="Orders" value="1,240" delta={8} hint="vs last month" /></SvCard>
          <SvCard><SvStat label="Customers" value="18.6k" delta={3} hint="vs last month" /></SvCard>
          <SvCard><SvStat label="Refunds" value="0.9%" delta={-0.4} invert hint="vs last month" /></SvCard>
        </div>
        <SvCard title="{labelOf[active]} overview">
          <p class="muted">This is the <strong>{labelOf[active]}</strong> page. Swap it for your own content - the shell (sidebar, breadcrumb, top bar, account menu) stays the same across every route.</p>
        </SvCard>
      {:else}
        <SvCard flush>
          <SvEmptyState
            title="{labelOf[active]} goes here"
            description="Drop your real {labelOf[active].toLowerCase()} view into this content region. The shell handles navigation and chrome."
          >
            <SvButton variant="primary" size="sm" onclick={() => toast('Create')}>Create {labelOf[active].toLowerCase()}</SvButton>
          </SvEmptyState>
        </SvCard>
      {/if}
    </main>
  </div>
</div>

<SvToaster position="bottom-right" />

<style>
  .shell { display: grid; grid-template-columns: 232px 1fr; height: 620px; border: 1px solid var(--sg-border, #eef2f7); border-radius: 12px; overflow: hidden; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
  .shell.collapsed { grid-template-columns: 64px 1fr; }
  .side { display: flex; flex-direction: column; border-right: 1px solid var(--sg-border, #eef2f7); background: var(--sg-header-bg, #f8fafc); min-height: 0; }
  /* Let SvNavPane fill the sidebar column (flush - no card chrome) so its
     internal scroll region gets a real height instead of collapsing to 0. */
  .side :global(.sv-nav) { width: auto; flex: 1 1 0; min-height: 0; border: 0; border-radius: 0; background: transparent; }
  .brand { display: flex; align-items: center; gap: 10px; padding: 16px; font-size: 16px; font-weight: 700; height: 56px; box-sizing: border-box; }
  .logo { font-size: 20px; color: var(--sg-accent, #4f46e5); }

  .main { display: flex; flex-direction: column; min-width: 0; }
  .bar { display: flex; align-items: center; gap: 10px; padding: 0 16px; height: 56px; border-bottom: 1px solid var(--sg-border, #eef2f7); }
  .spacer { flex: 1; }
  .search { width: 220px; }
  .icon-btn { background: none; border: 0; font-size: 17px; cursor: pointer; padding: 4px 8px; border-radius: 6px; color: var(--sg-muted, #475569); }
  .icon-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
  .acct { background: none; border: 0; padding: 0; cursor: pointer; display: inline-flex; }

  .content { padding: 22px; overflow: auto; display: flex; flex-direction: column; gap: 16px; background: var(--sg-header-bg, #f8fafc); }
  .page-head { display: flex; align-items: center; gap: 10px; }
  .page-head h2 { margin: 0; font-size: 22px; letter-spacing: -.01em; }
  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
  .muted { color: var(--sg-muted, #64748b); font-size: 14px; line-height: 1.55; margin: 0; }
  .ic { display: inline-flex; font-size: 13px; }

  @media (max-width: 720px) {
    .search { display: none; }
    .shell { grid-template-columns: 64px 1fr; }
    /* The 64px column is the collapsed rail's width, but `collapsed` is
       still the user's toggle - so hide the brand text and clip the nav
       pane's labels instead of letting them paint over the breadcrumb. */
    .brand strong { display: none; }
    .side { overflow: hidden; }
    /* The top bar wraps on a phone (bell + avatar drop to a second line);
       with a fixed 56px height that line painted over the page heading. */
    .bar { height: auto; min-height: 56px; padding: 8px 12px; }
    /* As a flex item that hides overflow, the shell's automatic minimum is
       0, so it shrank to whatever the demo stage had left. Keep its height
       and let the page scroll. */
    .shell { flex-shrink: 0; }
    /* The stat cards go 2-up here and the page grows past 620px; without a
       pinned row the implicit grid row grew with it and the shell clipped
       the bottom. Pin the row to the shell so `.content` scrolls, as it
       does on desktop. */
    .shell { grid-template-rows: minmax(0, 1fr); }
    /* `.content` is a flex column with a definite height, so its cards
       (hidden overflow, automatic minimum 0) squeezed instead of making
       it scroll. Keep their heights; the column scrolls. */
    .content > :global(*) { flex-shrink: 0; }
  }
</style>

View this example on GitHub

More Blocks examples

  • Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
  • Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
  • Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
  • Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
  • Pricing block - A three-tier plan grid with a monthly/annual SvSegmented toggle (annual applies the discount live), a highlighted "most popular" card, per-plan feature SvLists with check marks, and a CTA SvButton on each.