Skip to examples
Bento / Kitchen sink
Bento / compositions

Shells

The frame a screen sits in. A shell owns the main landmark, the skip link, and which region scrolls; the screen owns everything inside. Each is previewed in its own document, since a shell nested in this page would put a main inside a main.

AppShell

sticky header · sidebar that scrolls on its own · document scroll

The page scrolls as a document. Only the sidebar has its own scroll, so find-in-page, anchors, and the browser’s scroll restoration all keep working.

Narrow, the sidebar moves above the content instead of disappearing: navigation that vanishes cannot be reached.

Sourcecomponents/shells/doc.ts · components/shells/app-shell/doc.ts · components/shells/app-shell/app-shell.tsx · components/shells/app-shell/app-shell.module.css

components/shells/doc.ts

/**
 * shells — the frame a whole screen sits in.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # What belongs here
 *
 * A shell owns page-level concerns: the one main landmark, the skip link that
 * targets it, the header and navigation regions, viewport height, and which
 * region scrolls. It owns no content, no routes, and no data.
 *
 * # Rules for every member
 *
 * R1  A screen renders exactly one shell, and the shell renders exactly one
 *     main. Nothing inside a shell renders another main.
 * R2  The first focusable element is "Skip to content", which moves focus to
 *     main.
 * R3  Navigation is passed in: which link is current is the caller's
 *     decision, made from its router, so shells work in any framework.
 * R4  Navigation never disappears at narrow widths; it moves.
 * R5  Shells are demonstrated in their own documents (the shell-preview
 *     route), never nested inside another page, since R1 would break.
 */
export {};

components/shells/app-shell/doc.ts

/**
 * AppShell — header across the top, sidebar down the side, content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     nav        the sidebar, REQUIRED — usually a SidebarNav
 *     actions?   the header's end: theme, notifications, account
 *     header?    between the brand and the actions: search, a switcher
 *     brand?     replaces the wordmark
 *     children   the screen
 *
 * # Behaviour
 *
 * R1  The header sticks to the top; its height follows control density.
 * R2  The sidebar sticks under the header and scrolls on its own, so a long
 *     navigation and a long page do not drag each other.
 * R3  The page scrolls as a document: the browser's own scroll, find, and
 *     anchor behaviour are kept.
 * R4  Below 40rem the sidebar moves above the content.
 * R5  Main is focusable only by script, as the skip link's target.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * --header-h is derived from --control-md and the header padding, and the
 * sidebar's sticky offset and height read it. Main takes tabindex=-1 and an
 * id from useId for the skip link.
 */
export {};

components/shells/app-shell/app-shell.tsx

import { useId, type ReactNode } from "react";

import { Mark } from "@/components/chrome/mark";
import { cn } from "@/lib/utils/cn";
import styles from "./app-shell.module.css";

export type AppShellProps = {
  /** The sidebar: usually a SidebarNav, often inside a ContextSidebar. */
  nav: ReactNode;
  /** The header's end: theme toggle, notifications, account menu. */
  actions?: ReactNode;
  /** Between the brand and the actions: search, a workspace switcher. */
  header?: ReactNode;
  /** Replaces the wordmark. */
  brand?: ReactNode;
  children: ReactNode;
  className?: string;
};

/** Header, sidebar, main — and nothing about what goes in them. Owns the
 *  page's one <main>, which the skip link targets. */
export function AppShell({
  nav,
  actions,
  header,
  brand,
  children,
  className,
}: AppShellProps) {
  const id = useId();
  return (
    <div className={cn(styles.root, className)}>
      <a href={`#${id}-main`} className={styles.skip}>
        Skip to content
      </a>
      <header className={styles.header}>
        {brand ?? <Mark />}
        <div className={styles.headerContent}>{header}</div>
        {actions ? <div className={styles.actions}>{actions}</div> : null}
      </header>
      <div className={styles.body}>
        <aside className={styles.sidebar}>{nav}</aside>
        <main id={`${id}-main`} tabIndex={-1} className={styles.main}>
          {children}
        </main>
      </div>
    </div>
  );
}

components/shells/app-shell/app-shell.module.css

@layer composition {
  .root {
    /* Derived from what the header holds (sm controls plus its padding), so it
       follows compact density instead of a typed-in height. */
    --header-h: calc(var(--control-md) + var(--space-4) * 2 + 1px);
    --sidebar-w: 15rem;
    display: grid;
    min-height: 100dvh;
    grid-template-rows: auto minmax(0, 1fr);
    background: var(--surface-ground);
    color: var(--ink);
    font-family: var(--font-sans);
    font-size: var(--text-body, var(--text-13));
  }
  .skip {
    position: absolute;
    z-index: var(--z-toast);
    top: var(--space-3);
    left: var(--space-3);
    padding: var(--space-3) var(--space-5);
    border-radius: var(--radius-2);
    background: var(--fill);
    color: var(--fill-ink);
    transform: translateY(-200%);
  }
  .skip:focus {
    transform: none;
  }
  .header {
    position: sticky;
    z-index: var(--z-sticky);
    top: 0;
    display: flex;
    height: var(--header-h);
    align-items: center;
    gap: var(--space-6);
    padding: var(--space-4) var(--space-7);
    border-bottom: 1px solid var(--line);
    background: var(--surface-panel);
  }
  .headerContent {
    min-width: 0;
    flex: 1;
  }
  .actions {
    display: flex;
    align-items: center;
    gap: var(--space-3);
  }
  .body {
    display: grid;
    grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  }
  /* Its own scroll: a long nav does not drag the page, and a long page does
     not scroll the nav away. */
  .sidebar {
    position: sticky;
    top: var(--header-h);
    height: calc(100dvh - var(--header-h));
    overflow-y: auto;
    padding: var(--space-6) var(--space-5);
    border-right: 1px solid var(--line);
    background: var(--surface-rail);
  }
  .main {
    min-width: 0;
    padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
  }
  .main:focus {
    outline: none;
  }
  /* Narrow: the sidebar becomes a strip above the content rather than
     disappearing; a navigation that vanishes cannot be reached. */
  @media (max-width: 40rem) {
    .body {
      grid-template-columns: minmax(0, 1fr);
    }
    .sidebar {
      position: static;
      height: auto;
      border-right: 0;
      border-bottom: 1px solid var(--line);
    }
  }
}

RailShell

icon rail · collapsible section sidebar · contained scroll

Two levels of navigation. The rail picks the section; the sidebar lists its pages. Rail links are icons, so each is named by label and shows it as a tooltip.

A hidden sidebar is removed, not moved off screen: its links leave the tab order. The toggle reports aria-expanded.

It responds to its own width through a container query, so it lays out the same in this frame as in a window.

Sourcecomponents/shells/rail-shell/doc.ts · components/shells/rail-shell/rail-shell.tsx · components/shells/rail-shell/rail-shell.module.css · components/shells/navigation-rail/doc.ts · components/shells/navigation-rail/navigation-rail.tsx · components/shells/navigation-rail/navigation-rail.module.css · components/shells/context-sidebar/doc.ts · components/shells/context-sidebar/context-sidebar.tsx · components/shells/context-sidebar/context-sidebar.module.css

components/shells/rail-shell/doc.ts

/**
 * RailShell — icon rail, collapsible section sidebar, header, content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     rail                  the top-level sections, REQUIRED — a NavigationRail
 *     sidebar               the section's navigation, REQUIRED
 *     header?               after the sidebar toggle
 *     sidebarOpen? + onSidebarOpenChange   controlled
 *     defaultSidebarOpen?   uncontrolled, default true
 *     sidebarLabel?         names the sidebar region
 *     children              the screen
 *
 * # Behaviour
 *
 * R1  The shell fills its viewport and the content scrolls inside it; rail,
 *     sidebar, and header stay put.
 * R2  The toggle is a button naming what it will do, with aria-expanded and
 *     aria-controls pointing at the sidebar.
 * R3  A hidden sidebar is removed from the page, not just drawn off screen:
 *     its links cannot be tabbed to.
 * R4  Narrow (under 40rem of its own width) the sidebar opens below the
 *     header instead of beside it; the rail stays.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The shell is a size container, so R4 responds to the shell's width, not
 * the window's — it lays out the same in a preview frame. The sidebar uses
 * the hidden attribute (R3).
 */
export {};

components/shells/rail-shell/rail-shell.tsx

"use client";

import { useId, useState, type ReactNode } from "react";

import { Button } from "@/components/forms/button";
import { PanelLeftClose, PanelLeftOpen } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./rail-shell.module.css";

type SidebarState =
  | {
      sidebarOpen: boolean;
      onSidebarOpenChange: (open: boolean) => void;
      defaultSidebarOpen?: never;
    }
  | {
      sidebarOpen?: never;
      defaultSidebarOpen?: boolean;
      onSidebarOpenChange?: (open: boolean) => void;
    };

export type RailShellProps = SidebarState & {
  /** The icon rail: a NavigationRail. */
  rail: ReactNode;
  /** The section's own navigation: usually a ContextSidebar. */
  sidebar: ReactNode;
  /** After the sidebar toggle: a title, search, actions. */
  header?: ReactNode;
  children: ReactNode;
  /** Names the sidebar region. */
  sidebarLabel?: string;
  className?: string;
};

/** Rail, collapsible section sidebar, header, and content that scrolls on its
 *  own. Owns the page's one <main>. */
export function RailShell({
  rail,
  sidebar,
  header,
  children,
  sidebarLabel = "Section navigation",
  sidebarOpen,
  defaultSidebarOpen = true,
  onSidebarOpenChange,
  className,
}: RailShellProps) {
  const id = useId();
  const [localOpen, setLocalOpen] = useState(defaultSidebarOpen);
  const open = sidebarOpen ?? localOpen;
  function toggle() {
    if (sidebarOpen === undefined) setLocalOpen(!open);
    onSidebarOpenChange?.(!open);
  }
  return (
    <div className={cn(styles.root, className)}>
      <div className={styles.frame} data-sidebar-open={open || undefined}>
        <a href={`#${id}-main`} className={styles.skip}>
          Skip to content
        </a>
        <div className={styles.rail}>{rail}</div>
        <aside
          id={`${id}-sidebar`}
          aria-label={sidebarLabel}
          hidden={!open}
          className={styles.sidebar}
        >
          {sidebar}
        </aside>
        <header className={styles.header}>
          <Button
            size="icon"
            variant="quiet"
            aria-label={
              open ? "Hide section navigation" : "Show section navigation"
            }
            aria-expanded={open}
            aria-controls={`${id}-sidebar`}
            onClick={toggle}
          >
            {open ? (
              <PanelLeftClose aria-hidden="true" />
            ) : (
              <PanelLeftOpen aria-hidden="true" />
            )}
          </Button>
          <div className={styles.headerContent}>{header}</div>
        </header>
        <main id={`${id}-main`} tabIndex={-1} className={styles.main}>
          {children}
        </main>
      </div>
    </div>
  );
}

components/shells/rail-shell/rail-shell.module.css

@layer composition {
  /* Measures itself, so it lays out correctly in a preview frame as well as a
     full window. */
  .root {
    height: 100dvh;
    min-width: 0;
    background: var(--surface-ground);
    color: var(--ink);
    font-family: var(--font-sans);
    font-size: var(--text-body, var(--text-13));
    container: rail-shell / inline-size;
  }
  .frame {
    position: relative;
    display: grid;
    height: 100%;
    grid-template-columns: 56px 0 minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas:
      "rail sidebar header"
      "rail sidebar main";
    overflow: hidden;
  }
  .frame[data-sidebar-open] {
    grid-template-columns: 56px 15rem minmax(0, 1fr);
  }
  .rail {
    grid-area: rail;
    overflow-y: auto;
    border-right: 1px solid var(--line);
    background: var(--surface-rail);
  }
  .sidebar {
    grid-area: sidebar;
    min-width: 0;
    overflow-y: auto;
    padding: var(--space-6) var(--space-4);
    border-right: 1px solid var(--line);
    background: var(--surface-panel);
  }
  .sidebar[hidden] {
    display: none;
  }
  .header {
    display: flex;
    min-width: 0;
    align-items: center;
    gap: var(--space-4);
    grid-area: header;
    padding: var(--space-3) var(--space-6);
    border-bottom: 1px solid var(--line);
    background: var(--surface-panel);
  }
  .headerContent {
    min-width: 0;
    flex: 1;
  }
  .main {
    min-width: 0;
    min-height: 0;
    grid-area: main;
    overflow: auto;
    padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
    overscroll-behavior: contain;
  }
  .main:focus {
    outline: none;
  }
  .skip {
    position: absolute;
    z-index: var(--z-toast);
    top: var(--space-3);
    left: var(--space-3);
    padding: var(--space-3) var(--space-5);
    border-radius: var(--radius-2);
    background: var(--fill);
    color: var(--fill-ink);
    transform: translateY(-200%);
  }
  .skip:focus {
    transform: none;
  }
  /* Narrow: the sidebar opens below the header instead of beside it. */
  @container rail-shell (max-width: 40rem) {
    .frame,
    .frame[data-sidebar-open] {
      grid-template-columns: 56px minmax(0, 1fr);
      grid-template-rows: auto auto minmax(0, 1fr);
      grid-template-areas:
        "rail header"
        "rail sidebar"
        "rail main";
    }
    .sidebar {
      max-height: 16rem;
      border-right: 0;
      border-bottom: 1px solid var(--line);
    }
  }
}

components/shells/navigation-rail/doc.ts

/**
 * NavigationRail / RailLink — a narrow column of icon links to the app's
 * top-level sections.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     NavigationRail   brand?, footer?, label? (the landmark's name), children
 *     RailLink         label REQUIRED, description?, active?, asChild?
 *
 * # Behaviour
 *
 * R1  A rail link is named by `label`. An icon is never the only name.
 * R2  The name is shown as a tooltip on hover and focus, so sighted users
 *     get what screen reader users hear.
 * R3  The active link is marked aria-current="true": it is a section, and
 *     the page inside it may be anything.
 * R4  Footer links (settings, help) are pinned to the bottom but are still
 *     part of the navigation landmark. An account menu is not navigation:
 *     put it in the sidebar's footer instead.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * RailLink wraps its anchor in the shared Tooltip, placed to the right, and
 * sets aria-label, which the tooltip does not replace.
 */
export {};

components/shells/navigation-rail/navigation-rail.tsx

"use client";

import { Slot } from "@radix-ui/react-slot";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { Tooltip } from "@/components/overlays/tooltip";
import { cn } from "@/lib/utils/cn";
import styles from "./navigation-rail.module.css";

export type NavigationRailProps = {
  brand?: ReactNode;
  /** Pinned to the bottom of the navigation: help, settings. */
  footer?: ReactNode;
  /** Names the navigation landmark. */
  label?: string;
  children: ReactNode;
  className?: string;
};

/** A narrow column of icon links to the app's top-level sections. */
export function NavigationRail({
  brand,
  footer,
  label = "Application sections",
  children,
  className,
}: NavigationRailProps) {
  return (
    <div className={cn(styles.root, className)}>
      {brand ?? <span />}
      <nav aria-label={label} className={styles.nav}>
        <div className={styles.items}>{children}</div>
        {footer ? <div className={styles.items}>{footer}</div> : null}
      </nav>
    </div>
  );
}

export type RailLinkProps = Omit<ComponentPropsWithRef<"a">, "aria-label"> & {
  /** The accessible name; also the tooltip unless description is given. */
  label: string;
  description?: string;
  active?: boolean;
  asChild?: boolean;
};

/** An icon-only link. Named by `label`, never by its icon; the tooltip
 *  repeats the name for sighted pointer users. */
export function RailLink({
  label,
  description,
  active = false,
  asChild,
  className,
  ...props
}: RailLinkProps) {
  const Component = asChild ? Slot : "a";
  return (
    <Tooltip content={description ?? label} side="right">
      <Component
        {...props}
        aria-label={label}
        aria-current={active ? "true" : undefined}
        className={cn(styles.link, className)}
      />
    </Tooltip>
  );
}

components/shells/navigation-rail/navigation-rail.module.css

@layer composition {
  .root {
    display: grid;
    height: 100%;
    grid-template-rows: auto minmax(0, 1fr);
    justify-items: center;
    gap: var(--space-6);
    padding: var(--space-5) 0;
  }
  .nav {
    display: grid;
    min-height: 0;
    align-content: space-between;
    gap: var(--space-6);
  }
  .items {
    display: grid;
    align-content: start;
    gap: var(--space-2);
  }
  .link {
    display: grid;
    width: 36px;
    height: 36px;
    place-items: center;
    border-radius: var(--radius-2);
    color: var(--ink-3);
  }
  .link svg {
    width: 18px;
    height: 18px;
  }
  .link:hover {
    background: var(--surface-hover-2);
    color: var(--ink);
  }
  .link:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 0;
  }
  .link[aria-current]:not([aria-current="false"]) {
    background: var(--accent-tint);
    color: var(--accent);
  }
}

components/shells/context-sidebar/doc.ts

/**
 * ContextSidebar — a sidebar that says which section it belongs to.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         the section or workspace, REQUIRED
 *     description?  a line under it
 *     footer?       pinned to the bottom: the account, a switcher
 *     level?        heading level, default 2
 *     children      the section's navigation
 *
 * # Behaviour
 *
 * R1  The title is a heading, so the sidebar can be found by heading.
 * R2  The footer sits at the bottom of the sidebar even when the navigation
 *     is short, and after it when the navigation is long.
 */
export {};

components/shells/context-sidebar/context-sidebar.tsx

import type { ReactNode } from "react";

import { Heading, type HeadingProps } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./context-sidebar.module.css";

export type ContextSidebarProps = {
  /** The section or workspace the sidebar belongs to. */
  title: ReactNode;
  description?: ReactNode;
  /** Pinned to the bottom: the account, the workspace switcher. */
  footer?: ReactNode;
  level?: HeadingProps["level"];
  children: ReactNode;
  className?: string;
};

export function ContextSidebar({
  title,
  description,
  footer,
  level = 2,
  children,
  className,
}: ContextSidebarProps) {
  return (
    <div className={cn(styles.root, className)}>
      <div className={styles.head}>
        <Heading level={level} size="sm">
          {title}
        </Heading>
        {description ? (
          <Text size="sm" tone="quiet">
            {description}
          </Text>
        ) : null}
      </div>
      <div>{children}</div>
      {footer ? <div className={styles.footer}>{footer}</div> : null}
    </div>
  );
}

components/shells/context-sidebar/context-sidebar.module.css

@layer composition {
  .root {
    display: grid;
    min-height: 100%;
    grid-template-rows: auto minmax(0, 1fr) auto;
    gap: var(--space-7);
  }
  .head {
    display: grid;
    gap: var(--space-2);
    padding: 0 var(--space-4);
  }
  .footer {
    padding: var(--space-5) var(--space-4) 0;
    border-top: 1px solid var(--line);
  }
}

AuthShell

one column · required h1 · used by sign-in and sign-up

The title is required and is the page’s h1; the wordmark is not a heading. The real sign-in and sign-up pages use this shell.

Sourcecomponents/shells/auth-shell/doc.ts · components/shells/auth-shell/auth-shell.tsx · components/shells/auth-shell/auth-shell.module.css

components/shells/auth-shell/doc.ts

/**
 * AuthShell — the frame every signed-out screen shares.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         REQUIRED, the page's h1
 *     description?  under the title
 *     actions?      beside the brand: theme, language
 *     brand?        replaces the wordmark
 *     footer?       under the card: the link to the other door
 *     layout?       "centered" (default) | "split"
 *     aside?        split only: the other half — the product, a quote
 *     children      the form
 *
 * # Behaviour
 *
 * R1  Centred in the viewport in one narrow column; on a short viewport it
 *     scrolls instead of clipping.
 * R2  The title is required and is the h1: the brand is not a heading.
 * R3  The shell is the main landmark. It owns arrangement only; the form,
 *     its state, and its errors belong to the page.
 * R4  Split puts the form on the right half, without a card, and the aside
 *     on the left, held in view while the form scrolls. The aside is
 *     supplementary: it follows the form in reading order, and below a
 *     tablet's width it is left out and the form takes the screen.
 */
export {};

components/shells/auth-shell/auth-shell.tsx

import type { ReactNode } from "react";

import { Mark } from "@/components/chrome/mark";
import { Card } from "@/components/display/card";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./auth-shell.module.css";

export type AuthShellProps = {
  /** Required, and rendered as the page's h1: the wordmark is not a heading,
   *  and a reader needs something to orient on. */
  title: string;
  description?: ReactNode;
  /** Above the card, beside the brand: the theme toggle, a language menu. */
  actions?: ReactNode;
  /** Replaces the wordmark; wrap it in a link home if there is one. */
  brand?: ReactNode;
  /** Under the card: the link to the other door. */
  footer?: ReactNode;
  /** One centred card (default), or the form on one half of the screen and
   *  `aside` on the other. */
  layout?: "centered" | "split";
  /** The other half, in split: the product, a promise, a quote. It is
   *  supplementary, so it follows the form in reading order and is left out
   *  when the screen is narrow. */
  aside?: ReactNode;
  children: ReactNode;
  className?: string;
};

/** The frame every signed-out screen shares: centred, one narrow column, one
 *  card. It owns the arrangement only — the form belongs to the page. */
export function AuthShell({
  title,
  description,
  actions,
  brand,
  footer,
  layout = "centered",
  aside,
  children,
  className,
}: AuthShellProps) {
  const head = (
    <div className={styles.head}>
      <Heading level={1} size={layout === "split" ? "lg" : "md"}>
        {title}
      </Heading>
      {description ? <Text tone="muted">{description}</Text> : null}
    </div>
  );

  if (layout === "split")
    return (
      <main className={cn(styles.root, styles.split, className)}>
        <div className={styles.pane}>
          <div className={styles.top}>
            {brand ?? <Mark />}
            {actions}
          </div>
          <div className={styles.body}>
            {head}
            {children}
            {footer ? <div className={styles.footer}>{footer}</div> : null}
          </div>
        </div>
        {aside ? <aside className={styles.aside}>{aside}</aside> : null}
      </main>
    );

  return (
    <main className={cn(styles.root, className)}>
      <Container width="narrow">
        <div className={styles.column}>
          <div className={styles.top}>
            {brand ?? <Mark />}
            {actions}
          </div>
          <Card as="div" elevation="raised" className={styles.card}>
            {head}
            {children}
          </Card>
          {footer ? <div className={styles.footer}>{footer}</div> : null}
        </div>
      </Container>
    </main>
  );
}

components/shells/auth-shell/auth-shell.module.css

@layer composition {
  .root {
    display: grid;
    min-height: 100dvh;
    align-content: center;
    padding-block: var(--space-9);
    background: var(--surface-ground);
    color: var(--ink);
    font-family: var(--font-sans);
    font-size: var(--text-body, var(--text-13));
  }
  .column {
    display: grid;
    gap: var(--space-7);
  }
  .top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);
  }
  .card {
    display: grid;
    gap: var(--space-7);
    padding: var(--space-9);
  }
  .head {
    display: grid;
    gap: var(--space-3);
  }
  .footer {
    color: var(--ink-2);
    font-size: var(--text-body, var(--text-13));
    text-align: center;
  }

  /* ── Split: the form on one half, the aside on the other ── */
  .split {
    grid-template-areas: "aside pane";
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-content: stretch;
    padding-block: 0;
  }
  .pane {
    display: grid;
    min-width: 0;
    grid-area: pane;
    grid-template-rows: auto 1fr;
    padding: var(--space-7) var(--gutter);
  }
  .body {
    display: grid;
    width: min(100%, var(--narrow-max));
    align-content: center;
    gap: var(--space-7);
    margin-inline: auto;
    padding-block: var(--space-10);
  }
  .split .footer {
    text-align: start;
  }
  /* In reading order it comes after the form; on screen it is the left
     half, held in view while the form scrolls. */
  .aside {
    position: sticky;
    top: 0;
    height: 100dvh;
    min-width: 0;
    grid-area: aside;
    overflow: hidden;
    border-right: 1px solid var(--line);
  }
  @media (max-width: 56rem) {
    .split {
      grid-template-areas: "pane";
      grid-template-columns: minmax(0, 1fr);
    }
    .aside {
      display: none;
    }
  }
}

SidebarNav

groups as data · current by segment

Current is decided by path segment. On /projects/atlas, Projects is current (a page inside it), Overview at / is not, and /app would never match /apples.

Sourcecomponents/shells/sidebar-nav/doc.ts · components/shells/sidebar-nav/sidebar-nav.tsx · components/shells/sidebar-nav/current.ts · components/shells/sidebar-nav/sidebar-nav.module.css

components/shells/sidebar-nav/doc.ts

/**
 * SidebarNav — a sidebar's links, grouped, as data.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     groups       { label?, items: { href, label, icon?, badge?, exact? }[] }[]
 *     current?     the path the reader is on
 *     label?       names the navigation landmark, default "Sections"
 *     renderLink?  render each link with the framework's own link
 *
 * # Behaviour
 *
 * R1  One navigation landmark; each group is a list, named by its label.
 * R2  A link is current on its own path, and (unless exact) on any path
 *     inside it, so a detail page still marks its section.
 * R3  Paths are matched by segment: /app is not current on /apples.
 * R4  "/" is current only on "/".
 * R5  Several links may match; each is marked. Give overlapping links
 *     `exact` rather than relying on order.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * isCurrent() in current.ts implements R2–R4 and is exported for callers
 * that mark current links elsewhere (a rail). Each row is a NavLink.
 */
export {};

components/shells/sidebar-nav/sidebar-nav.tsx

import { useId, type ReactElement, type ReactNode } from "react";

import { NavLink } from "@/components/navigation/nav-link";
import type { LucideIcon } from "@/components/utility/icon";
import styles from "./sidebar-nav.module.css";
import { isCurrent } from "./current";

export type NavItem = {
  href: string;
  label: string;
  icon?: LucideIcon;
  /** A count or status after the label. */
  badge?: ReactNode;
  /** Current only on this exact path, not on pages inside it. */
  exact?: boolean;
};

export type NavGroup = {
  /** Heads the group, so a reader knows which part of the app it is. */
  label?: string;
  items: readonly NavItem[];
};

export type SidebarNavProps = {
  groups: readonly NavGroup[];
  /** The path the reader is on. A prop, never read from a router, so the
   *  component is the same in every framework. */
  current?: string;
  /** Names the navigation landmark. */
  label?: string;
  /** Render with the framework's Link to keep client navigation. */
  renderLink?: (item: NavItem, content: ReactNode) => ReactElement;
};

/** The sidebar's links, as data: a navigation is a list of links, and a
 *  product edits one array rather than hunting links through markup. */
export function SidebarNav({
  groups,
  current,
  label = "Sections",
  renderLink,
}: SidebarNavProps) {
  const id = useId();
  return (
    <nav aria-label={label} className={styles.root}>
      {groups.map((group, index) => {
        const headingId = group.label ? `${id}-${index}` : undefined;
        return (
          <div key={group.label ?? index} className={styles.group}>
            {group.label ? (
              <p id={headingId} className={styles.groupLabel}>
                {group.label}
              </p>
            ) : null}
            <ul className={styles.list} aria-labelledby={headingId}>
              {group.items.map((item) => {
                const Icon = item.icon;
                const content = (
                  <>
                    {Icon ? <Icon aria-hidden="true" /> : null}
                    {item.label}
                    {item.badge ? (
                      <span className={styles.badge}>{item.badge}</span>
                    ) : null}
                  </>
                );
                const active = isCurrent(item.href, current, item.exact);
                return (
                  <li key={item.href}>
                    {renderLink ? (
                      <NavLink asChild active={active}>
                        {renderLink(item, content)}
                      </NavLink>
                    ) : (
                      <NavLink href={item.href} active={active}>
                        {content}
                      </NavLink>
                    )}
                  </li>
                );
              })}
            </ul>
          </div>
        );
      })}
    </nav>
  );
}

components/shells/sidebar-nav/current.ts

/** Whether a nav link is where the reader is: the path itself, or (unless
 *  exact) a page inside it, so a detail route still marks its section.
 *  Matched by segment: /app is not current on /apples. "/" is never current
 *  by containment, or it would be current everywhere. */
export function isCurrent(href: string, current?: string, exact = false) {
  if (!current) return false;
  const path = current.split(/[?#]/)[0];
  const target = href.split(/[?#]/)[0];
  if (href === current || (!href.includes("?") && target === path)) return true;
  if (exact || target === "/") return false;
  return path.startsWith(`${target.replace(/\/$/, "")}/`);
}

components/shells/sidebar-nav/sidebar-nav.module.css

@layer composition {
  .root {
    display: grid;
    gap: var(--space-7);
  }
  .group {
    display: grid;
    gap: var(--space-1);
  }
  .groupLabel {
    margin: 0 0 var(--space-2);
    padding: 0 var(--space-4);
    color: var(--ink-3);
    font-size: var(--text-11);
    font-weight: var(--weight-bold);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
  }
  .list {
    display: grid;
    gap: var(--space-1);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .badge {
    margin-left: auto;
  }
}

In the app

/w/[workspace] · RailShell with real routes · placeholder data

The signed-in app, not a preview. Every page has its own address under the workspace, so links can be shared and the shell stays mounted between pages. The rail, the section's pages, the breadcrumb, and ⌘K all come from one navigation list; the numbers are placeholders until the screens are wired to the backend.

Mark

name · compact

full
Bento
compact
Bento
Sourcecomponents/chrome/mark/doc.ts · components/chrome/mark/mark.tsx · components/chrome/mark/mark.module.css

components/chrome/mark/doc.ts

/**
 * Mark — the product's wordmark.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     name?      the product name, default "Bento"
 *     compact?   glyph only, default false
 *
 * # Behaviour
 *
 * R1  Every place that shows the brand renders this, so replacing the brand
 *     is one file.
 * R2  Compact hides the name visually only; it is still read out.
 * R3  The mark is not a heading and not a link; wrap it in a link home where
 *     one exists.
 */
export {};

components/chrome/mark/mark.tsx

import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./mark.module.css";

export type MarkProps = ComponentPropsWithRef<"span"> & {
  /** The product name. */
  name?: string;
  /** Glyph only; the name stays available to assistive technology. */
  compact?: boolean;
};

/** The product's wordmark: a glyph and the name. Swap this file for a real
 *  logo; everything that shows the brand renders it through here. */
export function Mark({
  name = "Bento",
  compact = false,
  className,
  ...props
}: MarkProps) {
  return (
    <span
      {...props}
      className={cn(styles.root, compact && styles.compact, className)}
    >
      <span className={styles.glyph} aria-hidden="true">
        {name.charAt(0)}
      </span>
      <span className={styles.word}>{name}</span>
    </span>
  );
}

components/chrome/mark/mark.module.css

@layer primitive {
  .root {
    display: inline-flex;
    align-items: center;
    gap: var(--space-4);
    color: var(--ink);
    font-size: var(--text-15);
    font-weight: var(--weight-bold);
    letter-spacing: var(--tracking-tight);
    text-decoration: none;
  }
  .glyph {
    display: grid;
    width: 28px;
    height: 28px;
    flex: none;
    place-items: center;
    border-radius: var(--radius-2);
    background: var(--fill);
    color: var(--fill-ink);
    font-size: var(--text-15);
  }
  .compact .word {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}