Skip to content
CodeAtlas UI

Layout

AppShell

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

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

  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. 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.

Anatomy

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

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>

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.

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>

Props

PropTypeDefaultDescription
modulesNavModule[]–Navigation modules. Each has id, label, icon, basePath, optional pathPrefixes and items (NavItem or NavSection).
pathnamestring–Current location. Decides the active module and item.
onNavigate(path: string) => void–Called with the destination path when the rail, panel or bottom navigation is used.
defaultModulenumber0Module index used before any navigation matches.
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.
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, the skip link target. Pass `null` when your page renders its own `<main id="main" tabindex="-1">`.
skipLinkbooleantrueRenders the SkipLink. Turn it off when the shell is embedded in a page that already has one.
childrenSnippet–Page content.

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, 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).