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: 599px) {
    .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, GlassCard, Grid, 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}>
      <Grid cols="repeat(auto-fill,minmax(150px,1fr))" gap={12}>
        <KpiCard title="Revenue" value="$118k" trend={23.4} />
        <KpiCard title="Users" value="24.8k" trend={8.1} />
      </Grid>
      <GlassCard full>
        <p class="hint">Click a rail icon or a panel item. The demo keeps the pathname in local state.</p>
      </GlassCard>
    </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.
persistPanelbooleantruePersists the open or closed state of the panel in localStorage.
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.