Skip to examples
Bento / Kitchen sink
Bento / compositions

Page patterns

Regions every screen has, and the screens they add up to. Patterns own layout and slots; content, state, and permissions stay with the screen.

CollectionToolbar

filters · live summary · actions

24 of 24 projects

The summary is announced. It is a status region, so “12 of 24 projects” is read out when a filter changes it.

Sourcecomponents/patterns/collection-toolbar/doc.ts · components/patterns/collection-toolbar/collection-toolbar.tsx · components/patterns/collection-toolbar/collection-toolbar.module.css

components/patterns/collection-toolbar/doc.ts

/**
 * CollectionToolbar — the controls above a list, grid, or table.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     children   the filters and search, each separately labelled
 *     summary?   "12 of 48 projects"
 *     actions?   create, export, view switches
 *
 * # Behaviour
 *
 * R1  Filters on the left, summary and actions on the right; each group wraps
 *     on its own as space shrinks.
 * R2  The summary is a status region, so a change in the count after
 *     filtering is announced.
 * R3  It is a layout, not an ARIA toolbar: every control keeps its own tab
 *     stop and label, because filters are unrelated controls, not one widget.
 */
export {};

components/patterns/collection-toolbar/collection-toolbar.tsx

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

import { cn } from "@/lib/utils/cn";
import styles from "./collection-toolbar.module.css";

export type CollectionToolbarProps = ComponentPropsWithRef<"div"> & {
  /** "12 of 48 projects": updated as filters change. */
  summary?: ReactNode;
  /** At the end: create, export, view switches. */
  actions?: ReactNode;
};

/** Filters and search on the left, a summary and actions on the right. A
 *  layout for separately labelled controls, not an ARIA toolbar: each control
 *  keeps its own tab stop and label. */
export function CollectionToolbar({
  summary,
  actions,
  className,
  children,
  ...props
}: CollectionToolbarProps) {
  return (
    <div {...props} className={cn(styles.root, className)}>
      <div className={styles.controls}>{children}</div>
      {summary || actions ? (
        <div className={styles.trailing}>
          {summary ? (
            <span className={styles.summary} role="status">
              {summary}
            </span>
          ) : null}
          {actions}
        </div>
      ) : null}
    </div>
  );
}

components/patterns/collection-toolbar/collection-toolbar.module.css

@layer composition {
  .root {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5) var(--space-6);
  }
  .controls {
    display: flex;
    min-width: 0;
    flex: 1 1 18rem;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
  }
  .trailing {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-4);
  }
  .summary {
    color: var(--ink-3);
    font-size: var(--text-12);
    font-variant-numeric: tabular-nums;
  }
}

SelectionCard

a real radio or checkbox, the size of a card

For trying Bento with a small team.

$0 / month

Up to 50 seats and 100 GB.

$249 / month

Talk to sales.

Custom

Chosen plan: team

Link commits and pull requests.

Post updates to a channel.

Sync issues both ways.

The whole card is the target. It is still a radio in a RadioGroup, so arrow keys move between plans and the choice submits with a form.

Sourcecomponents/patterns/selection-card/doc.ts · components/patterns/selection-card/selection-card.tsx · components/patterns/selection-card/selection-card.module.css

components/patterns/selection-card/doc.ts

/**
 * SelectionCard — a choice presented as a card.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, value            REQUIRED
 *     description?, children? (price, features), disabled?
 *     mode "single"           a radio; place the cards in a RadioGroup
 *     mode "multiple"         a checkbox; name?, checked?, defaultChecked?,
 *                             onCheckedChange?
 *
 * # Behaviour
 *
 * R1  It is a real radio or checkbox, labelled by the card's label and
 *     described by its description, so it submits and is announced like one.
 * R2  A click anywhere on the card chooses it.
 * R3  A chosen card takes the accent border and tint; keyboard focus draws the
 *     ring around the whole card rather than the small control.
 * R4  In a RadioGroup, arrow keys move between cards, as between radios.
 */
export {};

components/patterns/selection-card/selection-card.tsx

"use client";

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

import { Checkbox } from "@/components/forms/checkbox";
import { Radio } from "@/components/forms/radio-group";
import { cn } from "@/lib/utils/cn";
import styles from "./selection-card.module.css";

type BaseProps = {
  label: string;
  value: string;
  description?: ReactNode;
  disabled?: boolean;
  /** Extra content below the description: a price, a feature list. */
  children?: ReactNode;
  className?: string;
};

export type SelectionCardProps = BaseProps &
  (
    | {
        /** One of several: place the cards inside a RadioGroup. */
        mode: "single";
      }
    | {
        mode: "multiple";
        name?: string;
        checked?: boolean;
        defaultChecked?: boolean;
        onCheckedChange?: (checked: boolean) => void;
      }
  );

/** A choice presented as a card: the whole card is the target, and it is a
 *  real radio or checkbox underneath. */
export function SelectionCard(props: SelectionCardProps) {
  const { label, value, description, disabled, children, className } = props;
  const id = useId();
  const labelId = `${id}-label`;
  const descriptionId = description ? `${id}-description` : undefined;
  const control = {
    id,
    value,
    disabled,
    "aria-labelledby": labelId,
    "aria-describedby": descriptionId,
  };

  return (
    <div className={cn(styles.root, className)}>
      <label id={labelId} htmlFor={id} className={styles.label}>
        {label}
      </label>
      <span className={styles.control}>
        {props.mode === "single" ? (
          <Radio {...control} />
        ) : (
          <Checkbox
            {...control}
            name={props.name}
            checked={props.checked}
            defaultChecked={props.defaultChecked}
            onCheckedChange={
              props.onCheckedChange
                ? (checked) => props.onCheckedChange?.(checked === true)
                : undefined
            }
          />
        )}
      </span>
      {description ? (
        <p id={descriptionId} className={styles.description}>
          {description}
        </p>
      ) : null}
      {children ? <div className={styles.body}>{children}</div> : null}
    </div>
  );
}

components/patterns/selection-card/selection-card.module.css

@layer composition {
  .root {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-content: start;
    gap: var(--space-3) var(--space-5);
    padding: var(--space-7);
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
    transition:
      border-color var(--dur-2) var(--ease),
      background-color var(--dur-2) var(--ease);
  }
  .control {
    grid-column: 2;
    grid-row: 1;
  }
  .label {
    grid-column: 1;
    grid-row: 1;
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    font-weight: var(--weight-strong);
    cursor: pointer;
  }
  /* The label covers the card, so a click anywhere on it chooses it. */
  .label::after {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    content: "";
  }
  .description {
    grid-column: 1 / -1;
    margin: 0;
    color: var(--ink-2);
    font-size: var(--text-12);
    line-height: var(--leading-body);
  }
  .body {
    grid-column: 1 / -1;
    margin-top: var(--space-3);
  }
  .root:where(:hover:not(:has(:disabled))) {
    border-color: var(--accent-line);
  }
  .root:has([data-state="checked"]) {
    border-color: var(--accent);
    background: var(--accent-tint);
  }
  /* The ring moves from the small control to the whole card. */
  .root:has(:focus-visible) {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .root:has(:focus-visible) :focus-visible {
    outline: none;
  }
  .root:has(:disabled) {
    opacity: 0.45;
  }
  .root:has(:disabled) .label {
    cursor: not-allowed;
  }
}

SettingsSection

label beside controls · stacks when narrow

Workspace name

Shown to everyone in the workspace and in invitations.

Sourcecomponents/patterns/settings-section/doc.ts · components/patterns/settings-section/settings-section.tsx · components/patterns/settings-section/settings-section.module.css

components/patterns/settings-section/doc.ts

/**
 * SettingsSection — one group of settings.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title, children     REQUIRED
 *     description?, footer? (Save, or the destructive action)
 *     level?              default 2
 *     tone?               "default" | "danger"
 *
 * # Behaviour
 *
 * R1  The title and description sit beside a card of controls (1:2) when the
 *     section is at least 44rem wide, and above it when narrower. It measures
 *     its own width, not the window's.
 * R2  The footer holds the section's commit action, end-aligned.
 * R3  `danger` frames irreversible actions: the title in `--crit` and the
 *     card outlined in `--crit-line`. Its action should confirm first.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * R1 is a container query, so the same section stacks inside a narrow column
 * and spreads out on a full-width page.
 */
export {};

components/patterns/settings-section/settings-section.tsx

import type { ReactNode } from "react";

import { Card, CardBody, CardFooter } from "@/components/display/card";
import { Heading, type HeadingProps } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./settings-section.module.css";

export type SettingsSectionProps = {
  title: ReactNode;
  description?: ReactNode;
  /** The controls. */
  children: ReactNode;
  /** The card's footer: usually Save, or the destructive action. */
  footer?: ReactNode;
  level?: HeadingProps["level"];
  /** "danger" frames irreversible actions in the critical tone. */
  tone?: "default" | "danger";
  className?: string;
};

/** One group of settings: what it is on the left, the controls on a card on
 *  the right; stacked when narrow. */
export function SettingsSection({
  title,
  description,
  children,
  footer,
  level = 2,
  tone = "default",
  className,
}: SettingsSectionProps) {
  return (
    <section
      className={cn(styles.root, tone === "danger" && styles.danger, className)}
    >
      <div className={styles.grid}>
        <div className={styles.copy}>
          <Heading level={level} size="sm" className={styles.title}>
            {title}
          </Heading>
          {description ? (
            <Text size="sm" tone="muted">
              {description}
            </Text>
          ) : null}
        </div>
        <Card as="div" className={styles.card}>
          <CardBody>{children}</CardBody>
          {footer ? <CardFooter>{footer}</CardFooter> : null}
        </Card>
      </div>
    </section>
  );
}

components/patterns/settings-section/settings-section.module.css

@layer composition {
  /* Adapts to its own width, not the viewport: a settings section in a narrow
     column stacks even on a wide screen. */
  .root {
    container-type: inline-size;
  }
  .grid {
    display: grid;
    gap: var(--space-6);
  }
  @container (min-width: 44rem) {
    .grid {
      grid-template-columns: minmax(12rem, 1fr) minmax(0, 2fr);
      gap: var(--space-9);
    }
  }
  .copy {
    display: grid;
    align-content: start;
    gap: var(--space-3);
  }
  .danger .title {
    color: var(--crit);
  }
  .danger .card {
    border-color: var(--crit-line);
  }
}

Collection page

recipe · header, toolbar, card grid, empty result

Projects

Everything the workspace is building, grouped by project.

24 of 24

Atlas

Margaret Hamilton

active

109 tasks · updated 11 Aug 2026

Beacon

Donald Knuth

active

181 tasks · updated Today

Cinder

Grace Hopper

active

139 tasks · updated 19 Aug 2026

Delta

Donald Knuth

active

231 tasks · updated 25 Aug 2026

Ember

Donald Knuth

paused

5 tasks · updated 26 Aug 2026

Fjord

Tim Berners-Lee

archived

90 tasks · updated 18 Aug 2026

Garnet

Barbara Liskov

active

213 tasks · updated Yesterday

Harbor

Frances Allen

archived

173 tasks · updated 28 Jul 2026

Iris

Ada Lovelace

archived

186 tasks · updated 19 Jul 2026

Detail page

recipe · breadcrumb, status, stats, tabs

Atlas

ActiveCreated 12 Mar 2026
Open tasks428 due this week
Members4
Completed68%+12% this month
StorageNot measuredNot tracked for this project
Owner
Ada Lovelace
Visibility
Workspace members
Region
Europe (Ireland)
Description
Shared infrastructure and tooling for the autumn release.

Settings page

recipe · sections, feedback, danger zone

Workspace settings

Changes apply to everyone in Northstar.

Profile

How the workspace appears to members and in invitations.

A sentence or two.

Notifications

Changes apply immediately.

Delete workspace

Deletes every project and file for all members. This cannot be undone.

Only the owner can delete a workspace.

Destructive actions confirm. The danger zone is framed in the critical tone and opens an AlertDialog whose focus starts on Cancel.

Onboarding step

recipe · progress, plan choice, continue

Choose a plan

You can change plans at any time.

3 seats, 1 GB. Free forever.

50 seats, 100 GB, email support.

250 seats, 1 TB, priority support.

Overview

recipe · greeting, alert, stats, activity, usage

Good afternoon, Ada

Here is what changed in Northstar this week.

2 limits nearly reached

Seats and API requests are above 80% of your plan.
Active members10of 50 seats
Projects24+3 this week
Tasks closed1,284+8% vs last week
Invoices due1$374.00 on 1 Oct

Recent activity

WhenWhoWhat
26 Sept, 17:24 UTCLinus Torvaldsproject.archived
26 Sept, 15:56 UTCAda Lovelacemember.invited
26 Sept, 13:54 UTCLinus Torvaldsapi_key.created
26 Sept, 12:57 UTCKatherine Johnsonmember.role_changed
26 Sept, 11:26 UTCAlan Turingproject.archived

Plan usage

Team plan · renews 1 Oct 2026

Seats

46 / 50

Storage

71.4 / 100

API requests

1,020,000 / 1,000,000