App Shell

Skip to content
New order

Dashboard

@use 'sprucecss/scss/spruce' as *;

.app-shell {
  --app-shell-sidebar-inline-size: 16rem;
  --app-shell-block-size: 100dvb;
  background-color: color('surface-muted');
  display: grid;
  min-block-size: var(--app-shell-block-size);

  @include breakpoint('md') {
    grid-template-columns: var(--app-shell-sidebar-inline-size) minmax(0, 1fr);
  }

  &__skip-link {
    @include visually-hidden-focusable;
    background-color: color('surface');
    inset-block-start: spacer('xs');
    inset-inline-start: spacer('xs');
    padding: spacer('xs') spacer('s');
    position: fixed;
    z-index: z-index('toast');
  }

  &__sidebar {
    background-color: color('surface');
    border-inline-end: config('border-width-base', $display) solid color('border');
    display: flex;
    flex-direction: column;
    gap: spacer('m');
    overflow-y: auto;
    padding: spacer('m') spacer('s');

    @include breakpoint('md') {
      block-size: var(--app-shell-block-size);
      inset-block-start: 0;
      position: sticky;
    }
  }

  &__brand {
    align-items: center;
    color: color('heading');
    display: flex;
    font-family: config('font-family-heading', $typography);
    font-weight: 700;
    gap: spacer('xs');
    padding-inline: spacer('xs');
    text-decoration: none;

    svg {
      block-size: 1.5rem;
      color: color('primary');
      inline-size: 1.5rem;
    }
  }

  &__navigation {
    flex-grow: 1;
    @include layout-stack('xs');
  }

  &__menu {
    list-style: none;
    padding: 0;
    @include layout-stack('xxs');
  }

  &__menu-title {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    font-weight: 700;
    margin-block-start: spacer('m');
    padding-inline: spacer('xs');
  }

  &__link {
    align-items: center;
    border-radius: radius('md');
    color: color('text');
    display: flex;
    font-weight: 500;
    gap: spacer('xs');
    padding: spacer('xs');
    text-decoration: none;
    @include transition;

    svg {
      block-size: 1.25rem;
      color: color('muted');
      flex-shrink: 0;
      inline-size: 1.25rem;
    }

    &:hover {
      background-color: color('surface-muted');
      color: color('heading');
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: -2px;
    }

    &[aria-current='page'] {
      background-color: color-alpha('primary', 0.1);
      color: color('primary');

      svg {
        color: currentColor;
      }
    }
  }

  &__count {
    background-color: color('primary');
    border-radius: radius('pill');
    color: color('primary-foreground', 'btn');
    font-size: 0.75rem;
    font-weight: 700;
    line-height: 1;
    margin-inline-start: auto;
    padding: 0.25em 0.6em;
  }

  &__user {
    align-items: center;
    border-block-start: config('border-width-base', $display) solid color('border');
    display: flex;
    gap: spacer('xs');
    padding: spacer('s') spacer('xs') 0;
  }

  &__avatar {
    align-items: center;
    aspect-ratio: 1;
    background-color: color-alpha('primary', 0.15);
    border-radius: radius('pill');
    color: color('primary');
    display: inline-flex;
    flex-shrink: 0;
    font-size: config('font-size-sm', $typography);
    font-weight: 700;
    inline-size: 2.5rem;
    justify-content: center;
  }

  &__user-info {
    display: grid;
    line-height: config('line-height-md', $typography);
    min-inline-size: 0;
  }

  &__user-name {
    color: color('heading');
    font-weight: 700;
  }

  &__user-email {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    @include text-ellipsis;
  }

  &__main {
    display: flex;
    flex-direction: column;
    min-inline-size: 0;
  }

  &__topbar {
    align-items: center;
    background-color: color('surface');
    border-block-end: config('border-width-base', $display) solid color('border');
    display: flex;
    gap: spacer('s');
    inset-block-start: 0;
    padding: spacer('xs') spacer('m');
    position: sticky;
    z-index: z-index('sticky');
  }

  &__toggle {
    display: none !important;
  }

  &__search {
    flex-grow: 1;
    max-inline-size: 24rem;
  }

  &__actions {
    @include layout-cluster('xs');
    flex-wrap: nowrap;
    margin-inline-start: auto;
    white-space: nowrap;
  }

  &__content {
    flex-grow: 1;
    padding: spacer-clamp('m', 'l');
    @include layout-stack('m');

    &:focus {
      outline: none;
    }
  }

  &__title {
    font-size: font-size('h3');
  }

  &__placeholder {
    background-color: color('surface');
    border: config('border-width-base', $display) dashed color('border');
    border-radius: radius('lg');
    min-block-size: 8rem;

    &--tall {
      min-block-size: 16rem;
    }
  }

  &__backdrop {
    background-color: color('overlay');
    inset: 0;
    position: fixed;
    z-index: z-index('overlay');
  }

  // Off-canvas sidebar below the md breakpoint, only when the JavaScript runs.
  // Without it, the sidebar stays in the flow above the content.
  &--enhanced {
    @include breakpoint-down('md') {
      .app-shell__toggle {
        display: inline-flex !important;
      }

      .app-shell__sidebar {
        box-shadow: shadow('xl');
        inline-size: min(var(--app-shell-sidebar-inline-size), 85vi);
        inset-block: 0;
        inset-inline-start: 0;
        position: fixed;
        translate: -100% 0;
        visibility: hidden;
        z-index: z-index('modal');
        @include transition($property: 'translate, visibility');
      }
    }
  }

  &--open {
    @include breakpoint-down('md') {
      .app-shell__sidebar {
        translate: 0 0;
        visibility: visible;
      }
    }
  }
}

[dir='rtl'] .app-shell--enhanced:not(.app-shell--open) .app-shell__sidebar {
  @include breakpoint-down('md') {
    translate: 100% 0;
  }
}
<div class="app-shell" data-app-shell>
    <a class="app-shell__skip-link" href="#app-shell-content">Skip to content</a>
    <aside class="app-shell__sidebar" id="app-shell-sidebar" aria-label="Sidebar">
        <a class="app-shell__brand" href="#">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m12 2 8 15H4z"/><path d="M12 17v5"/></svg>
            Acme Admin
        </a>
        <nav class="app-shell__navigation" aria-label="Main">
            <ul class="app-shell__menu" role="list">
                <li>
                    <a class="app-shell__link" href="#" aria-current="page">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
                        Dashboard
                    </a>
                </li>
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
                        Orders
                        <span class="app-shell__count">12<span class="sr-only"> new</span></span>
                    </a>
                </li>
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
                        Products
                    </a>
                </li>
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
                        Customers
                    </a>
                </li>
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
                        Analytics
                    </a>
                </li>
            </ul>
            <p class="app-shell__menu-title" id="app-shell-workspace">Workspace</p>
            <ul class="app-shell__menu" role="list" aria-labelledby="app-shell-workspace">
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
                        Settings
                    </a>
                </li>
                <li>
                    <a class="app-shell__link" href="#">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
                        Billing
                    </a>
                </li>
            </ul>
        </nav>
        <div class="app-shell__user">
            <span class="app-shell__avatar" aria-hidden="true">AS</span>
            <span class="app-shell__user-info">
                <span class="app-shell__user-name">Anna Smith</span>
                <span class="app-shell__user-email">anna@acme.com</span>
            </span>
        </div>
    </aside>
    <div class="app-shell__main">
        <header class="app-shell__topbar">
            <button class="btn btn--ghost-secondary btn--icon btn--sm app-shell__toggle" type="button" aria-controls="app-shell-sidebar" aria-expanded="false" data-app-shell-toggle>
                <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
                <span class="sr-only">Menu</span>
            </button>
            <form class="app-shell__search" role="search" action="#">
                <label class="sr-only" for="app-shell-search">Search</label>
                <input class="form-control form-control--sm" id="app-shell-search" type="search" name="q" placeholder="Search orders, products…">
            </form>
            <div class="app-shell__actions">
                <button class="btn btn--ghost-secondary btn--icon btn--sm" type="button">
                    <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
                    <span class="sr-only">Notifications (3 unread)</span>
                </button>
                <a class="btn btn--primary btn--sm" href="#">New order</a>
            </div>
        </header>
        <main class="app-shell__content" id="app-shell-content" tabindex="-1">
            <h1 class="app-shell__title">Dashboard</h1>
            <div class="app-shell__placeholder" aria-hidden="true"></div>
            <div class="app-shell__placeholder app-shell__placeholder--tall" aria-hidden="true"></div>
        </main>
    </div>
    <div class="app-shell__backdrop" data-app-shell-close hidden></div>
</div>
(() => {
  document.querySelectorAll('[data-app-shell]').forEach((shell) => {
    const toggle = shell.querySelector('[data-app-shell-toggle]');
    const backdrop = shell.querySelector('[data-app-shell-close]');
    const sidebar = document.getElementById(toggle.getAttribute('aria-controls'));
    const desktop = window.matchMedia('(min-width: 64em)');

    const setOpen = (open) => {
      shell.classList.toggle('app-shell--open', open);
      toggle.setAttribute('aria-expanded', String(open));
      backdrop.hidden = !open;

      if (open) {
        sidebar.querySelector('a, button')?.focus();
      }
    };

    // The off-canvas behavior only starts with JavaScript; without it, the sidebar stays in the flow.
    shell.classList.add('app-shell--enhanced');

    toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true'));
    backdrop.addEventListener('click', () => setOpen(false));

    shell.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && shell.classList.contains('app-shell--open')) {
        setOpen(false);
        toggle.focus();
      }
    });

    // Close the off-canvas sidebar when the viewport grows to the desktop layout.
    desktop.addEventListener('change', () => setOpen(false));
  });
})();

App Shell

The frame of an admin interface: a sidebar navigation, a sticky top bar with search and actions, and the main content area. Below the md breakpoint, the sidebar becomes an off-canvas panel.

Anatomy

  • .app-shell__sidebar: the brand, the navigation (.app-shell__menu lists with optional .app-shell__menu-title groups) and the signed-in user.
  • .app-shell__topbar: the menu toggle (mobile only), the search form and .app-shell__actions.
  • .app-shell__content: the main element; put the Page Header and your widgets here.

Accessibility and Usability

  • The first focusable element is a skip link to the main element (which has tabindex="-1", so it can receive the focus).
  • The current page is marked with aria-current="page"; the counter badge has hidden text ("12 new").
  • The menu toggle has aria-controls and aria-expanded. When the sidebar opens, the focus moves into it; Escape and a click on the backdrop close it and return the focus.
  • The closed off-canvas sidebar is visibility: hidden, so its links are not reachable with the keyboard.

Technical Details

  • Progressive enhancement: the off-canvas behavior starts only when dashboard/app-shell.js adds the .app-shell--enhanced class. Without JavaScript, the sidebar stays in the flow above the content on small screens.
  • The desktop sidebar is position: sticky and as tall as the shell. Change the size with the --app-shell-sidebar-inline-size (default 16rem) and --app-shell-block-size (default 100dvb) custom properties.
  • The breakpoint in the script (64em) must match the md breakpoint of your Spruce configuration.
  • The off-canvas sidebar slides from the inline start, so it comes from the right in right-to-left languages.
  • Set the brand with appShell ({ brand, title }), and fill the main area with appShellContent: capture your page content with {% set appShellContent %}…{% endset %} before the include. Without it, the shell shows a title and placeholders.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.