App shell

The page-level frame that positions a sidebar next to a main content column.

The shell composes .sidebar and .app-shell__main into a layout. It owns where the pieces sit and how the layout responds to viewport size. Sidebar and navbar internals live on their own pages. Demos below use placeholder slots in place of the rich panels.

Basic

.app-shell wraps a .sidebar and an .app-shell__main. The shell sets the sidebar's width via --app-shell-sidebar-width (default 16rem), and main fills the rest with flex-grow: 1.

Dashboard

Page content goes here.

<div class="app-shell overflow-hidden w-100" style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 22rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main p-4">
    <h3 class="h6">Dashboard</h3>
    <p class="text-muted-foreground">Page content goes here.</p>
  </main>
</div>

The demo overrides the shell's height for preview purposes. In production, drop the inline style and the default min-height: 100vh fills the viewport.

With top navbar

Add a direct .navbar child to the shell and wrap the sidebar and main in an .app-shell__body. The shell auto-detects this shape via :has() and switches to flex-column. No modifier class needed.

Dashboard

Page content goes here.

<div class="app-shell overflow-hidden w-100" style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 24rem;">
  <nav class="navbar py-3 px-4 text-muted-foreground fs-2" style="background: var(--st-surface-2); border-bottom: 1px solid var(--st-border);">Navbar</nav>
  <div class="app-shell__body">
    <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
    <main class="app-shell__main p-4">
      <h3 class="h6">Dashboard</h3>
      <p class="text-muted-foreground">Page content goes here.</p>
    </main>
  </div>
</div>

To keep the sidebar at the full page height with its own brand, leave the shell as a flex-row and put the navbar inside .app-shell__main. The navbar never extends across the sidebar.

Dashboard

Page content goes here.

<div class="app-shell overflow-hidden w-100" style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 24rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main">
    <nav class="navbar py-3 px-4 text-muted-foreground fs-2" style="background: var(--st-surface-2); border-bottom: 1px solid var(--st-border);">Navbar</nav>
    <div class="p-4 flex-fill">
      <h3 class="h6">Dashboard</h3>
      <p class="text-muted-foreground">Page content goes here.</p>
    </div>
  </main>
</div>

Drop a <footer> at the end of .app-shell__main. Main is already a flex column, so margin-top: auto on the footer pushes it to the bottom even when content is short.

Dashboard

Page content goes here.

© 2026 Acme. Built with Stisla.
<div class="app-shell overflow-hidden w-100" style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 22rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main">
    <div class="p-4">
      <h3 class="h6">Dashboard</h3>
      <p class="text-muted-foreground">Page content goes here.</p>
    </div>
    <footer class="fs-2 text-muted-foreground mt-auto py-3 px-4" style="border-top: 1px solid var(--st-border);">
      &copy; 2026 Acme. Built with Stisla.
    </footer>
  </main>
</div>

The shell defaults to min-height: 100vh. When the page is taller than the viewport, the window scrolls and the sidebar scrolls with it. To pin the sidebar to the viewport top, add four inline styles to .sidebar.

<div class="app-shell">
  <aside class="sidebar" style="
    position: sticky;
    top: 0;
    height: 100vh;
    align-self: flex-start;
  ">
    <!-- ... -->
  </aside>
  <main class="app-shell__main">
    <!-- long content here; the window scrolls, the sidebar stays put -->
  </main>
</div>

For a Gmail-style locked shell (no window scroll, main is the scroll container), use height: 100vh; overflow: hidden on the shell and overflow: auto on main. Both patterns are opt-in via inline style, and the shell stays out of the way.

Desktop collapse (rail)

Add .is-sidebar-collapsed to .app-shell to swap the sidebar to its rail width (--app-shell-sidebar-width-collapsed, default 4.25rem). Pair it with .is-collapsed on the sidebar itself for the rail visuals. Any button can wire the toggle with data-stisla-app-shell-toggle="collapse". Stisla.AppShell delegates the rail flip to the descendant Stisla.Sidebar instance so its rail visuals, ARIA, and submenu close-on-collapse stay in sync.

Click the icon to collapse the sidebar to a rail.

<div class="app-shell overflow-hidden w-100" data-stisla-app-shell style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 22rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main">
    <nav class="py-2 px-3" style="border-bottom: 1px solid var(--st-border);">
      <button class="btn btn--ghost btn--neutral btn--icon-only btn--sm" type="button" data-stisla-app-shell-toggle="collapse" aria-label="Toggle sidebar">
        <i data-lucide="panel-left"></i>
      </button>
    </nav>
    <div class="p-4 flex-fill">
      <p class="text-muted-foreground">Click the icon to collapse the sidebar to a rail.</p>
    </div>
  </main>
</div>

Mobile drawer

Below the lg breakpoint, the sidebar leaves the flex flow and sits fixed off-screen. Setting .is-sidebar-visible on the shell slides it in and shows a backdrop. Any button can flip the class with data-stisla-app-shell-toggle="visibility". Backdrop clicks and the Escape key dismiss the drawer. Opt out per shell with { dismissOnBackdrop: false } or { dismissOnEscape: false } via data-stisla-app-shell-opts.

Resize the browser below lg (992px) and tap the menu icon. A backdrop covers the rest of the shell while open; tap anywhere outside the sidebar or press Esc to dismiss.

<div class="app-shell overflow-hidden w-100" data-stisla-app-shell style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 22rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main">
    <nav class="py-2 px-3" style="border-bottom: 1px solid var(--st-border);">
      <button class="btn btn--ghost btn--neutral btn--icon-only btn--sm" type="button" data-stisla-app-shell-toggle="visibility" aria-label="Open sidebar">
        <i data-lucide="menu"></i>
      </button>
    </nav>
    <div class="p-4 flex-fill">
      <p class="text-muted-foreground">Resize the browser below <code>lg</code> (992px) and tap the menu icon. A backdrop covers the rest of the shell while open; tap anywhere outside the sidebar or press <kbd>Esc</kbd> to dismiss.</p>
    </div>
  </main>
</div>

Auto toggle (responsive)

data-stisla-app-shell-toggle="auto" picks the right action per viewport. visibility below the lg breakpoint, collapse above. Use this for the single hamburger or menu button most dashboards ship in their top bar. The trigger's aria-expanded tracks whichever state matches the current viewport and re-syncs when the user resizes across the breakpoint. Call Stisla.AppShell.getOrCreate(shell).toggleSidebarAuto() for the programmatic equivalent.

On desktop, the icon rails the sidebar. On mobile, it opens the drawer.

<div class="app-shell overflow-hidden w-100" data-stisla-app-shell style="border: 1px solid var(--st-border); border-radius: var(--st-radius); min-height: 0; transform: translate(0); height: 22rem;">
  <aside class="sidebar d-flex align-items-center justify-content-center text-muted-foreground fs-2" style="background: var(--st-surface-2); border-right: 1px solid var(--st-border);">Sidebar</aside>
  <main class="app-shell__main">
    <nav class="py-2 px-3" style="border-bottom: 1px solid var(--st-border);">
      <button class="btn btn--ghost btn--neutral btn--icon-only btn--sm" type="button" data-stisla-app-shell-toggle="auto" aria-label="Toggle sidebar">
        <i data-lucide="panel-left"></i>
      </button>
    </nav>
    <div class="p-4 flex-fill">
      <p class="text-muted-foreground">On desktop, the icon rails the sidebar. On mobile, it opens the drawer.</p>
    </div>
  </main>
</div>

Customization

Override any of these on a .app-shell instance (inline style, a wrapper class, a [data-theme] block) to retune that shell. The outer wrapper and the main column carry their own bg + color so a heavier chrome (gray surrounds, white main) is one-liner away.

VariableDefaultUse
--app-shell-bgvar(--st-background)Outer wrapper background
--app-shell-colorvar(--st-foreground)Outer wrapper text color
--app-shell-main-bgvar(--st-background)Main column background
--app-shell-main-colorvar(--st-foreground)Main column text color
--app-shell-sidebar-width16remDesktop sidebar width
--app-shell-sidebar-width-collapsed4.25remDesktop collapsed (rail) width
--app-shell-sidebar-mobile-bgvar(--st-surface)Mobile drawer surface (opaque so content underneath doesn't bleed through)
--app-shell-sidebar-mobile-shadow2px 0 8px -2px oklch(0 0 0 / 0.15)Drawer shadow, cast right-ward to signal the slide-from-left motion
--app-shell-sidebar-z1030Z-index of the mobile drawer; the backdrop sits one below
--app-shell-backdrop-bgoklch(0 0 0 / 0.5)Mobile backdrop color (fixed-dark in both themes)
--app-shell-transition-duration200msWidth and slide transition speed