Skip to content
On this page Panel width

Layout

AppShell

Application layout with a module rail, a navigation panel, a mobile header and a bottom navigation, independent of any router.

<script lang="ts">
  import { AppShellLayout, SiteHeader, type NavModule } from "@codeatlasdev/ui";
  import { goto } from "$app/navigation"; // or any router
  import { page } from "$app/state";

  let { children } = $props();

  const modules: NavModule[] = [/* id, label, icon, basePath, items */];
</script>

<AppShellLayout {modules} pathname={page.url.pathname} onNavigate={(path) => goto(path)}>
  {#snippet header()}<SiteHeader />{/snippet}
  {@render children()}
</AppShellLayout>

The shell never imports a router. You pass the current pathname and handle onNavigate, so it works with SvelteKit, Astro, or plain state. onNavigate fires when the user switches module from the rail or the bottom navigation. Panel items are plain <a href> links, so a client-side router takes them over like any other link. Put it only on application routes, never on marketing pages. The panel is skipped for a module with fewer than two destinations, since it would only repeat the rail icon and the page title. Below 900px the rail and panel disappear and the mobile header plus bottom navigation take over. See the templates for complete applications built on it.

The rail magnifies the module icon under the pointer, like a dock. Only the glyph grows, never the button, so the hover background cannot cover the active neighbour. The effect follows the motion scale: it is off with motion: { scale: 0 } in the theme and under prefers-reduced-motion.

#165

Anatomy

Rail (52px), panel (200px by default) and the main content area.

Reference #165 · Pick in the gallery

Code
svelte
<div class="shell-diagram" role="img" aria-label="App shell anatomy: rail, panel and main content">
  <div class="shell-rail">
    <span class="shell-label">Rail<br />52px</span>
    <div class="rail-icons">
      {#each [0, 1, 2, 3] as i}
        <div class="rail-icon" class:rail-icon--active={i === 1}></div>
      {/each}
    </div>
  </div>
  <div class="shell-panel">
    <span class="shell-label">Panel<br />200px</span>
    <div class="panel-items">
      {#each ['Dashboard', 'Users', 'Reports', 'Settings'] as item, i}
        <div class="panel-item" class:panel-item--active={i === 0}>{item}</div>
      {/each}
    </div>
  </div>
  <div class="shell-main">
    <span class="shell-label">Main content</span>
    <div class="main-topbar"></div>
    <div class="content-block"></div>
    <div class="content-block content-block--short"></div>
    <div class="content-block"></div>
  </div>
</div>

<style>
  .shell-diagram {
    display: flex;
    width: 100%;
    height: 280px;
    border-radius: var(--ca-radius-lg);
    overflow: hidden;
    box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
  }
  .shell-label {
    font-size: 10px;
    font-weight: 600;
    color: var(--ca-text-hint);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    line-height: 1.3;
    text-align: center;
  }
  .shell-rail {
    width: 52px;
    flex-shrink: 0;
    box-shadow: inset -1px 0 0 var(--ca-outline-subtle);
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 12px 0;
    gap: 16px;
  }
  .rail-icons { display: flex; flex-direction: column; gap: 8px; align-items: center; }
  .rail-icon { width: 32px; height: 32px; border-radius: var(--ca-radius-md); background: var(--ca-action-hover); }
  .rail-icon--active {
    background: color-mix(in oklch, var(--ca-brand-main) 20%, transparent);
    box-shadow: inset 3px 0 0 var(--ca-brand-main);
  }
  .shell-panel {
    width: 200px;
    flex-shrink: 0;
    box-shadow: inset -1px 0 0 var(--ca-outline-subtle);
    display: flex;
    flex-direction: column;
    padding: 12px 8px;
    gap: 12px;
  }
  .panel-items { display: flex; flex-direction: column; gap: 4px; }
  .panel-item {
    padding: 6px 10px;
    border-radius: var(--ca-radius-sm);
    font-size: var(--ca-font-size-caption);
    color: var(--ca-text-secondary);
  }
  .panel-item--active {
    background: color-mix(in oklch, var(--ca-brand-main) 12%, transparent);
    color: var(--ca-brand-text);
    font-weight: 600;
  }
  .shell-main { flex: 1; display: flex; flex-direction: column; padding: 12px; gap: 8px; overflow: hidden; min-width: 0; }
  .main-topbar { height: 40px; border-radius: var(--ca-radius-md); background: var(--ca-action-hover); flex-shrink: 0; }
  .content-block { height: 48px; border-radius: var(--ca-radius-md); background: var(--ca-action-hover); }
  .content-block--short { height: 32px; width: 60%; }

  @media (max-width: 599.98px) {
    .shell-panel { display: none; }
  }
</style>
#166

Live shell

The real component inside a fixed-height frame. The pathname lives in local state; click the rail and panel to navigate.

Dashboard

Dashboard

/app/dashboard

Revenue

$118k
↑+23.4%

Users

24.8k
↑+8.1%

Click a rail icon or a panel item. The demo keeps the pathname in local state.

Reference #166 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { AppShellLayout, PageShell, KpiCard, KpiGrid, Card, type NavModule } from '@codeatlasdev/ui';
  import { LayoutDashboard, Users, BarChart3, Settings, CreditCard, Activity } from '@lucide/svelte';

  const modules: NavModule[] = [
    {
      id: 'app',
      label: 'App',
      icon: LayoutDashboard,
      basePath: '/app',
      items: [
        { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, path: '/app/dashboard' },
        { id: 'users', label: 'Users', icon: Users, path: '/app/users' },
        { id: 'reports', label: 'Reports', icon: BarChart3, path: '/app/reports' },
      ],
    },
    {
      id: 'account',
      label: 'Account',
      icon: Settings,
      basePath: '/account',
      items: [
        { id: 'billing', label: 'Billing', icon: CreditCard, path: '/account/billing' },
        { id: 'activity', label: 'Activity', icon: Activity, path: '/account/activity' },
        { id: 'settings', label: 'Settings', icon: Settings, path: '/account/settings' },
      ],
    },
  ];

  // The shell is router-agnostic: the app owns the URL and feeds it back in.
  let pathname = $state('/app/dashboard');

  // Panel items are plain <a href> links, exactly what a client router intercepts. With no router
  // in this demo, do the same: take over in-stage link clicks and feed the path back in.
  function intercept(e: MouseEvent) {
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    const a = (e.target as Element).closest('a[href^="/"]');
    if (!a) return;
    e.preventDefault();
    pathname = a.getAttribute('href')!;
  }

  const titles: Record<string, string> = {
    '/app/dashboard': 'Dashboard',
    '/app/users': 'Users',
    '/app/reports': 'Reports',
    '/account/billing': 'Billing',
    '/account/activity': 'Activity',
    '/account/settings': 'Settings',
  };
</script>

<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div class="stage" onclick={intercept}>
  <AppShellLayout {modules} {pathname} onNavigate={(path) => (pathname = path)} persistPanel={false} mainId={null} skipLink={false}>
    <PageShell title={titles[pathname] ?? 'Page'} description={pathname}>
      <KpiGrid>
        <KpiCard title="Revenue" value="$118k" trend={23.4} />
        <KpiCard title="Users" value="24.8k" trend={8.1} />
      </KpiGrid>
      <Card material="glass" full>
        <p class="hint">Click a rail icon or a panel item. The demo keeps the pathname in local state.</p>
      </Card>
    </PageShell>
  </AppShellLayout>
</div>

<style>
  /* The shell sizes itself with --ca-vh and pins its sidebar and bottom nav with position: fixed.
     Overriding --ca-vh and creating a containing block (contain: layout) frames it inside the demo. */
  .stage {
    --ca-vh: 440px;
    position: relative;
    width: 100%;
    height: 440px;
    contain: layout paint;
    overflow: hidden;
    border-radius: var(--ca-radius-lg);
    box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
  }
  .hint {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
</style>
#480

Panel width

panelWidth={280} with resizablePanel: drag the edge or focus it and use the arrow keys.

Quarterly revenue forecast and planning

Wider panel

Drag the panel edge, or focus it and use the arrow keys.

The panel starts at 280px and holds between 160px and 480px.

Reference #480 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { AppShellLayout, PageShell, Card, type NavModule } from '@codeatlasdev/ui';
  import { Folder } from '@lucide/svelte';

  const modules: NavModule[] = [
    {
      id: 'projects',
      label: 'Projects',
      icon: Folder,
      basePath: '/projects',
      items: [
        { id: 'a', label: 'Quarterly revenue forecast and planning', icon: Folder, path: '/projects/revenue' },
        { id: 'b', label: 'Customer onboarding experience redesign', icon: Folder, path: '/projects/onboarding' },
        { id: 'c', label: 'Infrastructure cost review', icon: Folder, path: '/projects/infra' },
      ],
    },
    { id: 'other', label: 'Other', icon: Folder, basePath: '/other', items: [] },
  ];

  let pathname = $state('/projects/revenue');

  function intercept(e: MouseEvent) {
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    const a = (e.target as Element).closest('a[href^="/"]');
    if (!a) return;
    e.preventDefault();
    pathname = a.getAttribute('href')!;
  }
</script>

<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div class="stage" onclick={intercept}>
  <AppShellLayout {modules} {pathname} panelWidth={280} resizablePanel persistPanel={false} mainId={null} skipLink={false}>
    <PageShell title="Wider panel" description="Drag the panel edge, or focus it and use the arrow keys.">
      <Card material="glass" full>
        <p class="hint">The panel starts at 280px and holds between 160px and 480px.</p>
      </Card>
    </PageShell>
  </AppShellLayout>
</div>

<style>
  .stage {
    --ca-vh: 360px;
    position: relative;
    width: 100%;
    height: 360px;
    contain: layout paint;
    overflow: hidden;
    border-radius: var(--ca-radius-lg);
    box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
  }
  .hint {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
</style>

Props

PropTypeDefaultDescription
modulesNavModule[]–Navigation modules. Each has id, label, icon, basePath, optional pathPrefixes and items (NavItem or NavSection). Prefixes match whole path segments and the longest one wins, so a module at `basePath: "/"` only gets the pages no other module claims.
pathnamestring–Current location. Decides the active module and item. An item at `path: "/"` is active on `/` only.
onNavigate(path: string) => void–Called with the first path of the module when the rail or the bottom navigation switches module. Panel items are links and do not call it.
defaultModulenumber0Module index used when the URL matches no module and none was stored.
persistPanelbooleantrueKeeps the shell across page loads: panel open or closed, selected module and expanded sections (localStorage), panel scroll (sessionStorage). Restored before the first paint. Turn it off for demos and embeds that must always start from the defaults.
panelWidthnumber200Width of the navigation panel in px, for lists with long labels. Held between 160 and 480. The content offset and the open and close animation follow it, so nothing misaligns. Prefer it to overriding `--ca-panel-width`, which the layout does not read.
resizablePanelbooleanfalseAdds a drag handle on the panel's trailing edge. It is a keyboard-operable separator: Arrow keys move it by 8px, Shift by 32px, Home and End jump to the bounds. With `persistPanel` the chosen width is kept like the open state and restored before the first paint; `panelWidth` is the width used until the user picks one.
reorderComponent–Pass the exported `NavReorder` to enable `onReorder` on sections and modules. Opt-in so the drag engine is only bundled by apps that reorder.
itemMenuComponent–Pass the exported `NavItemMenu` to render `NavItem.actions` as a "…" menu. Opt-in so the dropdown menu is only bundled by apps that use it.
headerSnippet–Desktop header rendered above the page content. Hidden below 900px.
brandSnippet–Logo shown at the top of the rail and in the mobile header.
railBottomSnippet–Items pinned to the bottom of the rail, such as an avatar or notifications.
mobileHeaderActionsSnippet–Controls at the trailing edge of the mobile header. Use it for anything that must stay reachable below 900px.
mobileTitleSnippet–Replaces the plain page title in the mobile header, for example a tappable title that opens a sheet.
mainIdstring | null'main'`id` of the content region, which is the page `<main tabindex="-1">` landmark and the skip link target. Pass `null` when your page renders its own `<main id="main" tabindex="-1">`: the region becomes a plain `div`.
skipLinkbooleantrueRenders the SkipLink. Turn it off when the shell is embedded in a page that already has one.
childrenSnippet–Page content.

Panel width

The panel is 200px by default. For lists with long names pass panelWidth (px, held between 160 and 480): the content offset and the open and close animation follow it, so the page never misaligns. Add resizablePanel to let people choose: a handle sits on the panel’s trailing edge and is a keyboard-operable separator (role="separator" with aria-valuenow, aria-valuemin and aria-valuemax). Arrow keys move it by 8px, Shift by 32px, Home and End jump to the bounds. While the pointer holds the edge the panel follows it with no spring in between. With persistPanel the width is stored with the open state and restored before the first paint; panelWidth is the width used until the user picks one. A stored width is ignored when resizablePanel is off, so the prop stays the contract.

Rich panel items

A NavItem takes more than an icon and a label:

Field What it does
color A decorative dot before the label, any CSS color. The icon stays.
leading A snippet that replaces the icon (a color swatch, an avatar, an emoji). Decorative: the label is the name.
count A neutral total after the label (1.2k above 999). The link’s name carries the exact number, never “unread”. 0 renders.
badge Unread-style count: announced “N unread”, capped at 99+. Use count for a plain total.
actions A snippet of DropdownMenuItems. The row gets a “…” button named Actions for <label>, a sibling of the link, shown on hover and focus. Needs itemMenu={NavItemMenu} on the shell.

Reordering is per list: set onReorder on a NavSection (or on the NavModule for the items that sit outside any section) and pass reorder={NavReorder} to AppShellLayout. Each row gets a grip that works with the mouse, touch (press and hold) and the keyboard (Space lifts, arrows move, Enter drops, Escape cancels), and every step is announced. It is the same useDnd engine as drag and drop. The shell never reorders items for you: commit the move in your state and the new order flows back in. onReorder is not called when the drop lands where it started.

NavReorder and NavItemMenu are opt-in on purpose: the drag engine and the dropdown menu are only bundled by apps that import them, so a shell without per-item menus or reordering stays small.

<script lang="ts">
  import { AppShellLayout, DropdownMenuItem, NavItemMenu, NavReorder, type NavModule, type NavReorderEvent } from "@codeatlasdev/ui";

  type Row = { id: string; name: string; color: string; count: number };
  let rows = $state<Row[]>([]);
  function archive() {}

  function reorder(e: NavReorderEvent) {
    const next = rows.slice();
    next.splice(e.to, 0, ...next.splice(e.from, 1));
    rows = next;
  }
</script>

{#snippet menu()}
  <DropdownMenuItem onclick={archive}>{#snippet children()}Archive{/snippet}</DropdownMenuItem>
{/snippet}

<!-- items: rows.map((r) => ({ id: r.id, label: r.name, icon: Folder, path: `/p/${r.id}`, color: r.color, count: r.count, actions: menu })) -->
<!-- <AppShellLayout {modules} {pathname} reorder={NavReorder} itemMenu={NavItemMenu}> -->
#481

Color, count, actions and reorder

Each row has a color dot, a neutral count and a … menu; drag a row by its grip.

Design system

Workspaces

/projects/design

Open the … menu of a row, or drag a row by its grip.

Reference #481 · Pick in the gallery

Code
svelte
<script lang="ts">
  import {
    AppShellLayout,
    DropdownMenuItem,
    NavItemMenu,
    NavReorder,
    PageShell,
    Card,
    type NavModule,
    type NavReorderEvent,
  } from '@codeatlasdev/ui';
  import { Folder } from '@lucide/svelte';

  type Row = { id: string; name: string; color: string; count: number };
  let rows = $state<Row[]>([
    { id: 'design', name: 'Design system', color: '#e11d48', count: 42 },
    { id: 'web', name: 'Marketing site', color: '#2563eb', count: 1280 },
    { id: 'mobile', name: 'Mobile app', color: '#16a34a', count: 0 },
  ]);
  let log = $state('Open the … menu of a row, or drag a row by its grip.');

  function reorder(e: NavReorderEvent) {
    const next = rows.slice();
    const [moved] = next.splice(e.from, 1);
    next.splice(e.to, 0, moved);
    rows = next;
    log = `Moved ${moved.name} to position ${e.to + 1}.`;
  }

  const modules: NavModule[] = $derived([
    {
      id: 'projects',
      label: 'Projects',
      icon: Folder,
      basePath: '/projects',
      items: [
        {
          label: 'Workspaces',
          collapsible: false,
          onReorder: reorder,
          items: rows.map((r) => ({
            id: r.id,
            label: r.name,
            icon: Folder,
            path: `/projects/${r.id}`,
            color: r.color,
            count: r.count,
            actions: menu,
          })),
        },
      ],
    },
    { id: 'other', label: 'Other', icon: Folder, basePath: '/other', items: [] },
  ]);

  let pathname = $state('/projects/design');
  function intercept(e: MouseEvent) {
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    const a = (e.target as Element).closest('a[href^="/"]');
    if (!a) return;
    e.preventDefault();
    pathname = a.getAttribute('href')!;
  }
</script>

{#snippet menu()}
  <DropdownMenuItem onclick={() => (log = 'Favorite')}>{#snippet children()}Favorite{/snippet}</DropdownMenuItem>
  <DropdownMenuItem onclick={() => (log = 'Archive')} variant="destructive">{#snippet children()}Archive{/snippet}</DropdownMenuItem>
{/snippet}

<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div class="stage" onclick={intercept}>
  <AppShellLayout {modules} {pathname} reorder={NavReorder} itemMenu={NavItemMenu} persistPanel={false} mainId={null} skipLink={false}>
    <PageShell title="Workspaces" description={pathname}>
      <Card material="glass" full>
        <p class="hint">{log}</p>
      </Card>
    </PageShell>
  </AppShellLayout>
</div>

<style>
  .stage {
    --ca-vh: 360px;
    position: relative;
    width: 100%;
    height: 360px;
    contain: layout paint;
    overflow: hidden;
    border-radius: var(--ca-radius-lg);
    box-shadow: inset 0 0 0 1px var(--ca-outline-subtle);
  }
  .hint {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
</style>

State across page loads

On a multi-page site (Astro, a full reload) every link loads a new document, so a shell that only keeps state in memory reopens at its defaults: the panel, the module, the expanded groups and the panel scroll are all lost. With persistPanel (the default) the shell keeps them and puts them back before the first paint, the same way the theme script does.

What Where Key
Panel open or closed, selected module, dragged panel width, section open map localStorage ca-shell
Panel scroll, per module sessionStorage ca-shell-scroll

An inline script emitted right after the sidebar reads those keys and sets data-ca-panel="open|closed" and data-ca-module="<id>" on <html> before anything paints, so a panel the user closed is laid out closed from the first frame instead of flashing open. Stored values are validated and discarded when malformed. A section’s last open or closed choice wins over defaultOpen, but the section that holds the current page always opens. The module comes from the URL when modules matches it, and from the stored choice otherwise.

Render the shell on the server so the first paint is not blank: the inline script and the markup travel together. A client-only mount gets the same restore on hydration, and a page whose CSP blocks inline scripts too. Keep id stable on each NavModule (it is the stored module key) and each section label unique within a module (it is the stored section key).

Building blocks for a custom panel

AppShellLayout renders the panel for you. Rail, Panel, BottomNav and MobileHeader are exported too, and two of them are the pieces to reuse when you lay out your own navigation: PanelNavItem is the row the panel draws for one NavItem (icon or leading, color dot, label, count, unread badge, new dot, the actions menu and the active pip), and SectionGroup is a titled group of items that collapses.

<script lang="ts">
  import { PanelNavItem, SectionGroup, type NavSection } from "@codeatlasdev/ui";

  const section: NavSection = { label: "Reports", collapsible: true, items };
</script>

<SectionGroup {section} {pathname} />
<PanelNavItem item={items[0]} isActive={pathname === items[0].path} href={items[0].path} />

PanelNavItem renders a row with a plain <a> inside it (aria-current="page" when active); the actions button and the grip are siblings of the link, because a link cannot contain controls. SectionGroup opens itself when it holds the current page, and inside an AppShellLayout with persistPanel it stores its open state under the section label; a bare SectionGroup keeps nothing.

PanelNavItem props

PropTypeDefaultDescription
itemNavItem–The destination: `id`, `label`, `icon`, `path`, and optional `badge`, `count`, `color`, `leading`, `actions`, `isNew`, `shortcut`, `description`. Required.
isActiveboolean–Marks the row as the current page (`aria-current="page"`, active pip). Required.
hrefstring–Link target. Required.
gripbooleanfalseAdds the drag grip. Set it when the list the row sits in is reorderable (`NavSection.onReorder`).
describedbystring–`id` of the node that describes the keyboard drag (`dnd.instructionsId`), set on the grip.
classstring''Extra class names.

SectionGroup props

PropTypeDefaultDescription
sectionNavSection–`label`, `items`, and optional `collapsible` (default `true`), `defaultOpen` (default `true`) and `onReorder` (makes the items reorderable). Required.
pathnamestring–Current location, used to mark the active item and to keep the group holding it open. Required.
classstring''Extra class names.