Layout

App shell

The frame of an app: header, side navigation, main region, aside and footer, with a drawer on narrow screens.

Built on
Landmark elements<dialog>Invoker commandsContainer queriesResizeObserver
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.0 kB
Built from
ButtonDrawerSidebar
Skip to content
Larder

Menu

Code
import type { CSSProperties } from "react";
import { AppShell } from "@/components/equip/app-shell";

const tint = { borderRadius: "var(--eq-radius-s)", background: "var(--eq-tint-strong)" };

export default function Demo() {
  return (
    <AppShell
      mainAs="div"
      collapseBelow="never"
      header={<strong style={{ fontSize: "var(--eq-text-s)" }}>Larder</strong>}
      nav={
        <nav
          aria-label="Main"
          style={{ display: "grid", alignContent: "start", gap: 6, padding: 8, borderInlineEnd: "1px solid var(--eq-line)", background: "var(--eq-surface)" }}
        >
          {[0, 1, 2, 3].map((n) => (
            <span key={n} style={{ ...tint, width: 18, height: 18 }} />
          ))}
        </nav>
      }
      style={
        {
          "--eq-app-shell-height": "124px",
          width: 216,
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-m)",
        } as CSSProperties
      }
    >
      <div style={{ display: "grid", gap: 6, padding: 10 }}>
        <span style={{ ...tint, width: "55%", height: 10 }} />
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
          <span style={{ ...tint, height: 30 }} />
          <span style={{ ...tint, height: 30 }} />
        </div>
        <span style={{ ...tint, height: 14 }} />
      </div>
    </AppShell>
  );
}

Examples

A dashboard frame

collapseBelow="s": below 30rem of its own width the sidebar moves into a drawer behind the menu button and the activity column drops under the page. Tab in to reach the skip link.

Skip to content
LarderMira Holt

Overview

This month across all workspaces.

Revenue
€48,210
New customers
126
Open invoices
18
All systems normal

Menu

Code
import type { CSSProperties } from "react";
import { Button } from "@/components/equip/button";
import { Grid, GridItem } from "@/components/equip/grid";
import { PageHeader } from "@/components/equip/page-header";
import { Sidebar, SidebarItem, SidebarSection } from "@/components/equip/sidebar";
import { AppShell } from "@/components/equip/app-shell";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

const nav = (
  <Sidebar label="Main" style={{ "--eq-sidebar-width": "180px" } as CSSProperties}>
    <SidebarSection>
      <SidebarItem href="#overview" current icon={<Icon d="M2.5 2.5h4.5v6h-4.5Z M9 2.5h4.5v3.5H9Z M9 8h4.5v5.5H9Z M2.5 10.5h4.5v3H2.5Z" />}>
        Overview
      </SidebarItem>
      <SidebarItem href="#customers" icon={<Icon d="M8 7.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M3 13.5c.5-2.5 2.5-4 5-4s4.5 1.5 5 4" />}>
        Customers
      </SidebarItem>
      <SidebarItem href="#invoices" badge={3} icon={<Icon d="M4 1.5h8v13l-2-1.5-2 1.5-2-1.5-2 1.5Z M6 5h4 M6 8h4" />}>
        Invoices
      </SidebarItem>
      <SidebarItem href="#reports" icon={<Icon d="M3 13V8.5 M6.5 13V3.5 M10 13V6.5 M13.5 13v-3" />}>
        Reports
      </SidebarItem>
    </SidebarSection>
  </Sidebar>
);

const stats = [
  ["Revenue", "€48,210"],
  ["New customers", "126"],
  ["Open invoices", "18"],
];

export default function Example() {
  return (
    <AppShell
      mainAs="div"
      collapseBelow="s"
      header={
        <>
          <strong>Larder</strong>
          <span style={{ marginInlineStart: "auto", fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Mira Holt</span>
        </>
      }
      nav={nav}
      aside={
        <div style={{ padding: 16, fontSize: "var(--eq-text-s)" }}>
          <strong>Activity</strong>
          <ul style={{ margin: "8px 0 0", padding: 0, listStyle: "none", display: "grid", gap: 8, color: "var(--eq-ink-2)" }}>
            <li>Invoice 2041 paid by Fabrikam</li>
            <li>Kofi added a customer</li>
            <li>Q3 report exported</li>
          </ul>
        </div>
      }
      asideLabel="Activity"
      footer="All systems normal"
      style={
        {
          "--eq-app-shell-height": "460px",
          "--eq-app-shell-aside-width": "12rem",
          width: "100%",
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-l)",
        } as CSSProperties
      }
    >
      <div style={{ display: "grid", gap: 20, padding: 20 }}>
        <PageHeader
          level={2}
          title="Overview"
          description="This month across all workspaces."
          primaryAction={
            <Button size="s" variant="accent">
              New invoice
            </Button>
          }
        />
        <Grid min="9rem" gap={3}>
          {stats.map(([label, value]) => (
            <GridItem key={label} style={{ padding: 12, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)", background: "var(--eq-surface)" }}>
              <div style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{label}</div>
              <div style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>{value}</div>
            </GridItem>
          ))}
          <GridItem span="full" style={{ height: 140, borderRadius: "var(--eq-radius-m)", background: "var(--eq-tint)" }} />
        </Grid>
      </div>
    </AppShell>
  );
}

A settings page with a collapsible sidebar

nav as a function renders the toggle only beside the page; in the drawer the same links appear without it.

Skip to content
Settings

Profile

Shown to people in your workspace.

Sessions

Signed in on 3 devices.

Settings

Code
"use client";

import type { CSSProperties } from "react";
import { Button } from "@/components/equip/button";
import { Section } from "@/components/equip/section";
import { Sidebar, SidebarItem, SidebarSection, SidebarToggle } from "@/components/equip/sidebar";
import { AppShell } from "@/components/equip/app-shell";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

const label = { display: "grid", gap: 4, fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" } as const;
const input = {
  height: 34,
  padding: "0 12px",
  border: "1px solid var(--eq-line-strong)",
  borderRadius: "var(--eq-radius-m)",
  background: "var(--eq-raised)",
  color: "var(--eq-ink)",
  font: "inherit",
  fontWeight: "var(--eq-weight)",
} as const;

export default function Example() {
  return (
    <AppShell
      mainAs="div"
      collapseBelow="s"
      navLabel="Settings"
      header={<strong>Settings</strong>}
      nav={(placement) => (
        <Sidebar label="Settings" style={{ "--eq-sidebar-width": "190px" } as CSSProperties}>
          {placement === "inline" && <SidebarToggle />}
          <SidebarSection label="Account">
            <SidebarItem href="#profile" current icon={<Icon d="M8 7.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M3 13.5c.5-2.5 2.5-4 5-4s4.5 1.5 5 4" />}>
              Profile
            </SidebarItem>
            <SidebarItem href="#security" icon={<Icon d="M8 1.5 13 3.5v4c0 3-2 5.5-5 7-3-1.5-5-4-5-7v-4Z" />}>Security</SidebarItem>
            <SidebarItem href="#billing" icon={<Icon d="M2 4.5h12v7H2Z M2 7h12" />}>Billing</SidebarItem>
          </SidebarSection>
        </Sidebar>
      )}
      style={
        {
          "--eq-app-shell-height": "380px",
          width: "100%",
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-l)",
        } as CSSProperties
      }
    >
      <div style={{ display: "grid", gap: 24, padding: 20 }}>
        <Section id="shell-profile" layout="split" title="Profile" description="Shown to people in your workspace.">
          <div style={{ display: "grid", gap: 12, maxWidth: 320 }}>
            <label style={label}>
              Display name
              <input style={input} defaultValue="Mira Holt" />
            </label>
            <label style={label}>
              Email
              <input style={input} type="email" defaultValue="mira@larder.example" />
            </label>
          </div>
        </Section>
        <Section id="shell-sessions" layout="split" divider title="Sessions" description="Signed in on 3 devices.">
          <Button variant="outline">Sign out everywhere</Button>
        </Section>
      </div>
    </AppShell>
  );
}

On a phone

The menu button opens the navigation as a modal drawer: focus moves in, the page behind is inert, Escape or a chosen link closes it and focus lands on the main region.

Skip to content
Orders
  • #3021 Linen overshirt
  • #3020 Canvas tote
  • #3019 Rain shell
  • #3018 Merino crew
  • #3017 Wool beanie

Menu

Code
import type { CSSProperties } from "react";
import { Sidebar, SidebarItem, SidebarSection } from "@/components/equip/sidebar";
import { AppShell } from "@/components/equip/app-shell";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

const orders = ["#3021 Linen overshirt", "#3020 Canvas tote", "#3019 Rain shell", "#3018 Merino crew", "#3017 Wool beanie"];

export default function Example() {
  return (
    <AppShell
      mainAs="div"
      header={<strong>Orders</strong>}
      nav={
        <Sidebar label="Main">
          <SidebarSection>
            <SidebarItem href="#orders" current badge={5} icon={<Icon d="M2.5 4.5h11l-1 9h-9Z M5.5 4.5a2.5 2.5 0 0 1 5 0" />}>
              Orders
            </SidebarItem>
            <SidebarItem href="#products" icon={<Icon d="M2.5 5 8 2.5 13.5 5v6L8 13.5 2.5 11Z M2.5 5 8 7.5 13.5 5 M8 7.5v6" />}>Products</SidebarItem>
            <SidebarItem href="#customers" icon={<Icon d="M8 7.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M3 13.5c.5-2.5 2.5-4 5-4s4.5 1.5 5 4" />}>Customers</SidebarItem>
          </SidebarSection>
        </Sidebar>
      }
      style={
        {
          "--eq-app-shell-height": "320px",
          width: "100%",
          maxWidth: 340,
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-l)",
        } as CSSProperties
      }
    >
      <ul style={{ margin: 0, padding: "8px 16px", listStyle: "none" }}>
        {orders.map((order) => (
          <li key={order} style={{ padding: "10px 0", borderBottom: "1px solid var(--eq-line)", fontSize: "var(--eq-text-s)" }}>
            {order}
          </li>
        ))}
      </ul>
    </AppShell>
  );
}

Props

PropTypeDefaultWhat it does
headerReactNodenoneBrand, search and account controls. The menu button is placed before them when narrow.
navReactNode | (placement: "inline" | "drawer") => ReactNodenoneThe side navigation, usually a Sidebar. In the drawer a Sidebar is frameless and cannot fold, without any props from you. A function renders it differently beside the page and in the drawer.
navLabelstring"Menu"Names the menu button and titles the drawer.
asideReactNodenoneA secondary column after the main region.
asideLabelstring"Details"Names the aside landmark.
footerReactNodenoneA strip under the main region.
collapseBelow"s" | "m" | "l" | "never""m"The shell's own width below which the nav moves into a drawer: 30rem, 48rem or 64rem.
mainAs"main" | "div""main"The main region's element. div only when the shell sits inside a page that already has a main.
skipLabelstring"Skip to content"The text of the skip link.
...restComponentProps<"div">noneEvery native div attribute, on the root.
--eq-app-shell-heightCSS length100dvhThe shell's height. The main region scrolls inside it.
--eq-app-shell-aside-widthCSS length18remThe width of the aside column.

Anatomy

root.eq-app-shellThe size container the layout answers to.
skip link.eq-app-shell-skipHidden until focused.
frame.eq-app-shell-frameThe grid of named areas.
header.eq-app-shell-header
menu button.eq-app-shell-menuA ghost Button, shown only when the nav is in the drawer.
header content.eq-app-shell-header-content
nav column.eq-app-shell-navHolds the nav beside the main region; its width is the Sidebar's own.
main.eq-app-shell-main
aside.eq-app-shell-aside
footer.eq-app-shell-footer
drawer.eq-app-shell-drawerA Drawer docked to the start edge.
drawer header.eq-app-shell-drawer-headerThe drawer title and its close button.
drawer nav.eq-app-shell-drawer-navHolds the nav in the drawer, inside SidebarDefaults with frame and collapsible off.

Keyboard

TabFrom the top of the shell, reaches the skip link first.
EnterOn the skip link, moves focus to the main region.
EnterSpaceOn the menu button, opens the navigation drawer and moves focus into it.
TabShift+TabIn the drawer, moves between its links; the page behind is inert.
EscapeCloses the drawer and returns focus to the menu button.

Notes

  • The header, nav, main, aside and footer are landmark elements, so screen reader users can jump between them.
  • The nav is rendered twice, beside the page and in the drawer; only one is ever displayed, so assistive tech meets one navigation landmark.
  • Choosing a link in the drawer closes it and moves focus to the main region, where the new content arrives.
  • If the shell grows wide while the drawer is open, the drawer closes, so the page is never left inert behind a hidden button.
  • It collapses by its own width, not the viewport's, so it also works inside a preview, a panel or an embedded tool.
  • The examples pass mainAs="div" only because this site already has a main element; in an app keep the default.
  • Pass nav as a function from a client component; a server component cannot pass a function to it.
  • The shell never restyles the Sidebar: the drawer wraps it in SidebarDefaults (frame and collapsible off) and both nav columns set --eq-sidebar-height: auto on themselves.
  • The drawer uses invoker commands with the Drawer part's fallback for older engines. Container queries are in every current engine.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/app-shell.tsx175 lines
"use client";

import { useEffect, useId, useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import { Button } from "./button";
import { Drawer, DrawerTitle } from "./drawer";
import { SidebarDefaults } from "./sidebar";
import "./app-shell.css";

export type AppShellNavPlacement = "inline" | "drawer";

export type AppShellProps = ComponentProps<"div"> & {
  /** Brand, search and account controls. The menu button is placed before them when narrow. */
  header?: ReactNode;
  /**
   * The side navigation, usually a Sidebar. It is rendered beside the main region and again
   * inside the drawer, where a Sidebar is frameless and cannot fold. Pass a function to render
   * it differently in each place.
   */
  nav?: ReactNode | ((placement: AppShellNavPlacement) => ReactNode);
  /** Names the drawer and the button that opens it. */
  navLabel?: string;
  /** A secondary column after the main region, such as details or activity. */
  aside?: ReactNode;
  /** Names the aside landmark. */
  asideLabel?: string;
  /** A strip under the main region. */
  footer?: ReactNode;
  /**
   * The shell's own width below which the navigation moves into a drawer and the aside drops
   * under the main region: s is 30rem, m 48rem, l 64rem. never keeps the wide layout.
   */
  collapseBelow?: "s" | "m" | "l" | "never";
  /**
   * The element of the main region. Use div only when the shell sits inside a page that
   * already has a main element.
   */
  mainAs?: "main" | "div";
  /** The text of the skip link, the first thing Tab reaches. */
  skipLabel?: string;
  children?: ReactNode;
};

function MenuIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
      <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  );
}

/**
 * The frame of an application: header, side navigation, main region, optional aside and
 * footer. Set its height with --eq-app-shell-height (100dvh by default).
 */
export function AppShell({
  header,
  nav,
  navLabel = "Menu",
  aside,
  asideLabel = "Details",
  footer,
  collapseBelow = "m",
  mainAs = "main",
  skipLabel = "Skip to content",
  className,
  children,
  ...rest
}: AppShellProps) {
  const id = useId();
  const mainId = `${id}-main`;
  const drawerId = `${id}-nav`;
  const rootRef = useRef<HTMLDivElement>(null);
  const mainRef = useRef<HTMLElement>(null);
  const menuRef = useRef<HTMLButtonElement>(null);
  const drawerRef = useRef<HTMLDialogElement>(null);
  // main and div take the same attributes here, so the main typing holds for both.
  const Main = mainAs as "main";
  const renderNav = (placement: AppShellNavPlacement) => (typeof nav === "function" ? nav(placement) : nav);
  const hasNav = nav != null;

  // A modal drawer left open while the shell grows wide would keep the page inert behind a
  // hidden trigger, so it closes as soon as the menu button is no longer shown.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || !hasNav) return;
    const observer = new ResizeObserver(() => {
      const drawer = drawerRef.current;
      const menu = menuRef.current;
      if (drawer?.open && menu && getComputedStyle(menu).display === "none") drawer.close();
    });
    observer.observe(root);
    return () => observer.disconnect();
  }, [hasNav]);

  function skip(event: MouseEvent<HTMLAnchorElement>) {
    event.preventDefault();
    mainRef.current?.focus();
  }

  // Choosing a link in the drawer closes it and hands focus to the main region, where the new
  // page content arrives.
  function closeOnLink(event: MouseEvent<HTMLDialogElement>) {
    if (!(event.target instanceof Element) || !event.target.closest("a[href]")) return;
    event.currentTarget.close();
    mainRef.current?.focus({ preventScroll: true });
  }

  return (
    <div
      {...rest}
      ref={rootRef}
      className={["eq-app-shell", className].filter(Boolean).join(" ")}
      data-collapse-below={collapseBelow}
    >
      <a className="eq-app-shell-skip" href={`#${mainId}`} onClick={skip}>
        {skipLabel}
      </a>
      <div className="eq-app-shell-frame" data-nav={hasNav || undefined} data-aside={aside != null || undefined}>
        {(header != null || hasNav) && (
          <header className="eq-app-shell-header">
            {hasNav && (
              <Button
                ref={menuRef}
                className="eq-app-shell-menu"
                variant="ghost"
                size="s"
                aria-label={navLabel}
                aria-haspopup="dialog"
                command="show-modal"
                commandfor={drawerId}
              >
                <MenuIcon />
              </Button>
            )}
            <div className="eq-app-shell-header-content">{header}</div>
          </header>
        )}
        {hasNav && <div className="eq-app-shell-nav">{renderNav("inline")}</div>}
        <Main ref={mainRef} id={mainId} tabIndex={-1} className="eq-app-shell-main">
          {children}
        </Main>
        {aside != null && (
          <aside className="eq-app-shell-aside" aria-label={asideLabel}>
            {aside}
          </aside>
        )}
        {footer != null && <footer className="eq-app-shell-footer">{footer}</footer>}
      </div>
      {hasNav && (
        <Drawer ref={drawerRef} id={drawerId} side="left" className="eq-app-shell-drawer" onClick={closeOnLink}>
          <div className="eq-app-shell-drawer-header">
            <DrawerTitle>{navLabel}</DrawerTitle>
            <Button variant="ghost" size="s" square aria-label="Close" command="close" commandfor={drawerId}>
              <CloseIcon />
            </Button>
          </div>
          <div className="eq-app-shell-drawer-nav">
            {/* In the drawer a Sidebar drops its own frame and toggle: the drawer frames it, and a rail makes no sense there. */}
            <SidebarDefaults frame={false} collapsible={false}>
              {renderNav("drawer")}
            </SidebarDefaults>
          </div>
        </Drawer>
      )}
    </div>
  );
}
components/equip/app-shell.css284 lines
@layer equip {
  .eq-app-shell {
    position: relative;
    box-sizing: border-box;
    height: var(--eq-app-shell-height, 100dvh);
    min-width: 0;
    overflow: hidden;
    background: var(--eq-bg);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
    /* The shell is the container: it collapses by its own width, so it works in a panel or a preview too. */
    container: eq-app-shell / inline-size;
  }

  /* Hidden until it has keyboard focus, then shown over the top corner of the shell. */
  .eq-app-shell-skip {
    position: absolute;
    top: var(--eq-space-2);
    inset-inline-start: var(--eq-space-2);
    z-index: 3;
    display: inline-flex;
    align-items: center;
    height: var(--eq-h-m);
    padding-inline: var(--eq-space-3);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
    text-decoration: none;
    opacity: 0;
    translate: 0 -4px;
    pointer-events: none;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-app-shell-skip:not(:focus) {
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-app-shell-skip:focus {
    opacity: 1;
    translate: 0;
    pointer-events: auto;
  }

  .eq-app-shell-skip:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-app-shell-frame {
    box-sizing: border-box;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-rows: auto minmax(0, 1fr) auto;
    grid-template-areas:
      "header header header"
      "nav main aside"
      "nav footer aside";
    height: 100%;
  }

  .eq-app-shell-header {
    grid-area: header;
    z-index: 1;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-width: 0;
    min-height: var(--eq-h-l);
    padding-block: var(--eq-space-2);
    padding-left: max(var(--eq-space-3), env(safe-area-inset-left, 0px));
    padding-right: max(var(--eq-space-3), env(safe-area-inset-right, 0px));
    border-bottom: 1px solid var(--eq-line);
    background: var(--eq-surface);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
  }

  /* Shown only when the navigation is in the drawer. */
  .eq-app-shell-header > .eq-app-shell-menu {
    display: none;
    flex: none;
    width: var(--eq-h-s);
    padding: 0;
  }

  .eq-app-shell-header-content {
    flex: 1;
    display: flex;
    align-items: center;
    gap: var(--eq-space-3);
    min-width: 0;
  }

  .eq-app-shell-nav {
    grid-area: nav;
    display: flex;
    min-height: 0;
    /* A Sidebar here stretches to the column rather than taking a percentage height. */
    --eq-sidebar-height: auto;
  }

  .eq-app-shell-main {
    grid-area: main;
    box-sizing: border-box;
    min-width: 0;
    min-height: 0;
    overflow: auto;
    overscroll-behavior: contain;
    /* Focus scrolled into view keeps its ring clear of the edge. */
    scroll-padding-block: var(--eq-space-2);
  }

  /* It takes focus from the skip link and from a link in the drawer; the ring would only frame the page. */
  .eq-app-shell-main:focus {
    outline: none;
  }

  .eq-app-shell-aside {
    grid-area: aside;
    box-sizing: border-box;
    width: var(--eq-app-shell-aside-width, 18rem);
    min-height: 0;
    overflow: auto;
    border-inline-start: 1px solid var(--eq-line);
    background: var(--eq-surface);
  }

  .eq-app-shell-footer {
    grid-area: footer;
    box-sizing: border-box;
    min-width: 0;
    padding: var(--eq-space-2) var(--eq-space-4);
    border-top: 1px solid var(--eq-line);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  /* ---- The drawer: the same navigation, docked to the start edge ---- */

  .eq-drawer.eq-app-shell-drawer {
    width: min(18rem, calc(100vw - var(--eq-space-6)));
    padding: var(--eq-space-3);
    gap: var(--eq-space-2);
  }

  .eq-app-shell-drawer-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--eq-space-2);
    padding-inline-start: var(--eq-space-2);
  }

  /* The shell renders a Sidebar here frameless and without its toggle (see SidebarDefaults in
     app-shell.tsx), so it fills the drawer's width; it grows with its links and this box scrolls. */
  .eq-app-shell-drawer-nav {
    flex: 1;
    min-height: 0;
    overflow: auto;
    --eq-sidebar-height: auto;
  }

  /* ---- Narrow: navigation in the drawer, aside under the main region, the frame scrolls ---- */

  @container eq-app-shell (width < 30rem) {
    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame {
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: auto 1fr auto auto;
      grid-template-areas: "header" "main" "aside" "footer";
      overflow: auto;
      overscroll-behavior: contain;
      scroll-padding-block: var(--eq-space-2);
    }

    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame > .eq-app-shell-nav {
      display: none;
    }

    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame > .eq-app-shell-header {
      position: sticky;
      top: 0;
    }

    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame > .eq-app-shell-header > .eq-app-shell-menu {
      display: inline-flex;
    }

    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame > .eq-app-shell-main {
      min-height: auto;
      overflow: visible;
    }

    .eq-app-shell[data-collapse-below="s"] > .eq-app-shell-frame > .eq-app-shell-aside {
      width: auto;
      overflow: visible;
      border-inline-start: 0;
      border-top: 1px solid var(--eq-line);
    }
  }

  @container eq-app-shell (width < 48rem) {
    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame {
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: auto 1fr auto auto;
      grid-template-areas: "header" "main" "aside" "footer";
      overflow: auto;
      overscroll-behavior: contain;
      scroll-padding-block: var(--eq-space-2);
    }

    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame > .eq-app-shell-nav {
      display: none;
    }

    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame > .eq-app-shell-header {
      position: sticky;
      top: 0;
    }

    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame > .eq-app-shell-header > .eq-app-shell-menu {
      display: inline-flex;
    }

    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame > .eq-app-shell-main {
      min-height: auto;
      overflow: visible;
    }

    .eq-app-shell[data-collapse-below="m"] > .eq-app-shell-frame > .eq-app-shell-aside {
      width: auto;
      overflow: visible;
      border-inline-start: 0;
      border-top: 1px solid var(--eq-line);
    }
  }

  @container eq-app-shell (width < 64rem) {
    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame {
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: auto 1fr auto auto;
      grid-template-areas: "header" "main" "aside" "footer";
      overflow: auto;
      overscroll-behavior: contain;
      scroll-padding-block: var(--eq-space-2);
    }

    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame > .eq-app-shell-nav {
      display: none;
    }

    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame > .eq-app-shell-header {
      position: sticky;
      top: 0;
    }

    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame > .eq-app-shell-header > .eq-app-shell-menu {
      display: inline-flex;
    }

    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame > .eq-app-shell-main {
      min-height: auto;
      overflow: visible;
    }

    .eq-app-shell[data-collapse-below="l"] > .eq-app-shell-frame > .eq-app-shell-aside {
      width: auto;
      overflow: visible;
      border-inline-start: 0;
      border-top: 1px solid var(--eq-line);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index