Skip to examples
Bento / Kitchen sink
Bento / primitives

Display

Read-only presentation of records and values. Tones reinforce text; they never replace it.

Card

header · title · description · body · footer · elevation

Northstar

Workspace · 12 members

Active

The team’s shared space for planning the autumn release.

Raised

For a card that sits above the page.

Seats used18 / 25

A body alone, with no header or footer.

Footers line up. A footer sticks to the bottom, so cards of different heights in one row align their actions.

Sourcecomponents/display/card/doc.ts · components/display/card/card.tsx · components/display/card/card.variants.ts · components/display/card/card.module.css

components/display/card/doc.ts

/**
 * Card — a framed unit of content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Card              as? ("article" | "section" | "div", default article),
 *                       elevation? ("flat" | "raised", default flat)
 *     CardHeader        children (title, description), actions?
 *     CardTitle         a Heading; level defaults to 3, size to "sm"
 *     CardDescription   a paragraph under the title
 *     CardBody          the content
 *     CardFooter        actions or metadata, end-aligned, above a rule
 *
 * # Behaviour
 *
 * R1  `flat` sits on `--surface-panel` with a `--line` border and `--radius-3`;
 *     `raised` sits on `--surface-raised` with `--shadow`.
 * R2  Every part pads with `--space-7`; a body following a header drops its
 *     top padding so the two read as one block.
 * R3  The footer sticks to the bottom, so cards of different content heights
 *     in one grid row align their footers.
 * R4  Header actions stay at the end of the title row and never wrap under
 *     the title's first line.
 * R5  A card is an article by default: something that stands on its own. Use
 *     a div when it only frames part of a page.
 *
 * # Deliberately absent (for now)
 *
 * A whole-card link and a media slot. A picture on a card is an AspectRatio
 * inside CardBody until a real screen asks for more.
 */
export {};

components/display/card/card.tsx

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

import { Heading, type HeadingProps } from "@/components/typography/heading";
import { cn } from "@/lib/utils/cn";
import styles from "./card.module.css";
import { cardVariants, type CardVariants } from "./card.variants";

// Without a ref: one ref type cannot fit all three `as` elements.
export type CardProps = ComponentPropsWithoutRef<"article"> &
  CardVariants & {
    /** An article when it stands on its own (a record, a post); a div when it
     *  is only a frame around part of a page. */
    as?: "article" | "section" | "div";
  };

export function Card({
  as: Tag = "article",
  elevation,
  className,
  ...props
}: CardProps) {
  return (
    <Tag {...props} className={cn(cardVariants({ elevation }), className)} />
  );
}

export type CardHeaderProps = ComponentPropsWithRef<"div"> & {
  /** Controls at the end of the header: a menu, a status, a link. */
  actions?: ReactNode;
};

export function CardHeader({
  actions,
  className,
  children,
  ...props
}: CardHeaderProps) {
  return (
    <div {...props} className={cn(styles.header, className)}>
      <div className={styles.heading}>{children}</div>
      {actions ? <div className={styles.actions}>{actions}</div> : null}
    </div>
  );
}

export type CardTitleProps = Omit<HeadingProps, "level"> & {
  level?: HeadingProps["level"];
};

/** A heading, level 3 unless the page outline needs another. */
export function CardTitle({
  level = 3,
  size = "sm",
  ...props
}: CardTitleProps) {
  return <Heading {...props} level={level} size={size} />;
}

export function CardDescription({
  className,
  ...props
}: ComponentPropsWithRef<"p">) {
  return <p {...props} className={cn(styles.description, className)} />;
}

export function CardBody({
  className,
  ...props
}: ComponentPropsWithRef<"div">) {
  return <div {...props} className={cn(styles.body, className)} />;
}

export function CardFooter({
  className,
  ...props
}: ComponentPropsWithRef<"div">) {
  return <div {...props} className={cn(styles.footer, className)} />;
}

components/display/card/card.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./card.module.css";

export const cardVariants = cva(styles.root, {
  variants: {
    elevation: { flat: null, raised: styles.raised },
  },
  defaultVariants: { elevation: "flat" },
});

export type CardVariants = VariantProps<typeof cardVariants>;

components/display/card/card.module.css

@layer primitive {
  .root {
    display: flex;
    min-width: 0;
    flex-direction: column;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
    color: var(--ink);
    overflow-wrap: anywhere;
  }
  .raised {
    background: var(--surface-raised);
    box-shadow: var(--shadow);
  }
  .header {
    display: flex;
    align-items: flex-start;
    gap: var(--space-5);
    padding: var(--space-7);
  }
  .heading {
    display: grid;
    min-width: 0;
    flex: 1;
    gap: var(--space-2);
  }
  .actions {
    display: flex;
    flex: none;
    align-items: center;
    gap: var(--space-3);
  }
  .description {
    margin: 0;
    color: var(--ink-2);
    font-size: var(--text-12);
    line-height: var(--leading-body);
  }
  .body {
    display: grid;
    min-width: 0;
    flex: 1;
    align-content: start;
    gap: var(--space-5);
    padding: var(--space-7);
  }
  /* The header already spaces the body from the card's top edge. */
  .header + .body {
    padding-top: 0;
  }
  .footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-4);
    margin-top: auto;
    padding: var(--space-5) var(--space-7);
    border-top: 1px solid var(--line);
  }
}

Badge

tone · dot

tone
neutralaccentinfowarncrit
dot
HealthyDegradedDown
Sourcecomponents/display/badge/doc.ts · components/display/badge/badge.tsx · components/display/badge/badge.variants.ts · components/display/badge/badge.module.css

components/display/badge/doc.ts

/**
 * Badge — a short status or category.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     tone?   "neutral" | "accent" | "info" | "warn" | "crit"   default neutral
 *     dot?    boolean
 *
 * # Behaviour
 *
 * R1  A pill: `--text-11`, strong, on the tone's tint with its line colour.
 * R2  The text is the meaning; tone and dot only reinforce it. `dot` adds a
 *     mark so the tone survives greyscale, hidden from assistive technology.
 * R3  One line, never wrapping.
 */
export {};

components/display/badge/badge.tsx

import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./badge.module.css";
import { badgeVariants, type BadgeVariants } from "./badge.variants";

export type BadgeProps = ComponentPropsWithRef<"span"> &
  BadgeVariants & {
    /** A dot before the text, so the tone does not rest on colour alone. */
    dot?: boolean;
  };

export function Badge({
  tone,
  dot = false,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span {...props} className={cn(badgeVariants({ tone }), className)}>
      {dot ? <span className={styles.dot} aria-hidden="true" /> : null}
      {children}
    </span>
  );
}

components/display/badge/badge.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./badge.module.css";

export const badgeVariants = cva(styles.root, {
  variants: {
    tone: {
      neutral: styles.neutral,
      accent: styles.accent,
      info: styles.info,
      warn: styles.warn,
      crit: styles.crit,
    },
  },
  defaultVariants: { tone: "neutral" },
});

export type BadgeVariants = VariantProps<typeof badgeVariants>;

components/display/badge/badge.module.css

@layer primitive {
  .root {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 2px var(--space-4);
    border: 1px solid;
    border-radius: var(--radius-pill);
    font-size: var(--text-11);
    font-weight: var(--weight-strong);
    line-height: var(--leading-snug);
    white-space: nowrap;
  }
  .neutral {
    border-color: var(--line);
    background: var(--surface-hover);
    color: var(--ink-2);
  }
  .accent {
    border-color: var(--accent-line);
    background: var(--accent-tint);
    color: var(--accent);
  }
  .info {
    border-color: var(--info-line);
    background: var(--info-tint);
    color: var(--info);
  }
  .warn {
    border-color: var(--warn-line);
    background: var(--warn-tint);
    color: var(--warn);
  }
  .crit {
    border-color: var(--crit-line);
    background: var(--crit-tint);
    color: var(--crit);
  }
  /* A mark beside the colour, so the tone survives greyscale and colour
     blindness. The text is still the announcement. */
  .dot {
    width: 6px;
    height: 6px;
    flex: none;
    border-radius: var(--radius-pill);
    background: currentColor;
  }
}

Stat

label · value · hint — unmeasured is not zero

Active members1,284+8% this month
Median latency42 msp95 · all regions
Errors today0
ChurnNot measuredCollected monthly

A missing value is a dash, never 0. “Errors today” measured zero; “Churn” was not measured, and is read as “Not measured”.

Sourcecomponents/display/stat/doc.ts · components/display/stat/stat.tsx · components/display/stat/stat.module.css

components/display/stat/doc.ts

/**
 * Stat — one headline number with its label.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label   content, REQUIRED
 *     value?  content — undefined means "not measured"
 *     hint?   content — a comparison or a unit
 *
 * # Behaviour
 *
 * R1  The label sits above the value in the uppercase label style; the value
 *     is `--text-30` in the display face with tabular figures.
 * R2  An undefined value renders as a dash in `--ink-4` and is read as "Not
 *     measured". It never renders as 0: a zero is a measurement, and showing
 *     one for an unmeasured total asserts something nobody checked.
 */
export {};

components/display/stat/stat.tsx

import type { ReactNode } from "react";

import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import styles from "./stat.module.css";

export type StatProps = {
  label: ReactNode;
  /** `undefined` means nobody measured it, and renders as a dash rather than
   *  0: a zero is a measurement, a dash is the absence of one. */
  value?: ReactNode;
  hint?: ReactNode;
  className?: string;
};

export function Stat({ label, value, hint, className }: StatProps) {
  const unmeasured = value === undefined;
  return (
    <div className={cn(styles.root, className)}>
      <span className={styles.label}>{label}</span>
      <span className={cn(styles.value, unmeasured && styles.unmeasured)}>
        {unmeasured ? (
          <>
            <span aria-hidden="true">–</span>
            <VisuallyHidden>Not measured</VisuallyHidden>
          </>
        ) : (
          value
        )}
      </span>
      {hint ? <span className={styles.hint}>{hint}</span> : null}
    </div>
  );
}

components/display/stat/stat.module.css

@layer primitive {
  .root {
    display: grid;
    min-width: 0;
    gap: var(--space-2);
  }
  .label {
    color: var(--ink-3);
    font-size: var(--text-11);
    font-weight: var(--weight-strong);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
  }
  .value {
    color: var(--ink);
    font-family: var(--font-display, var(--font-sans));
    font-size: var(--text-30);
    font-weight: var(--heading-weight, var(--weight-bold));
    font-variant-numeric: tabular-nums;
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tight);
  }
  .unmeasured {
    color: var(--ink-4);
    font-weight: var(--weight-regular);
  }
  .hint {
    color: var(--ink-3);
    font-size: var(--text-12);
  }
}

Avatar

name · src · size

size
Sam RiveraSam RiveraSam Rivera
in a row
Alex Kim

Alex Kim

Owner

Sourcecomponents/display/avatar/doc.ts · components/display/avatar/avatar.tsx · components/display/avatar/avatar.variants.ts · components/display/avatar/avatar.module.css

components/display/avatar/doc.ts

/**
 * Avatar — a person, as an image or initials.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     name    string, REQUIRED
 *     src?    image URL
 *     size?   "sm" (20px) | "md" (28px) | "lg" (40px)          default "md"
 *
 * # Behaviour
 *
 * R1  With `src`, the image fills a circle and `name` is its alternative
 *     text.
 * R2  Without it, up to two initials from `name` show on `--accent-tint`;
 *     the initials are hidden from assistive technology and `name` is read
 *     instead, so the avatar always identifies someone.
 * R3  `name` also shows as a tooltip on hover.
 */
export {};

components/display/avatar/avatar.tsx

import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import styles from "./avatar.module.css";
import { avatarVariants, type AvatarVariants } from "./avatar.variants";

export type AvatarProps = AvatarVariants & {
  /** The person's name. Required even with an image: it is the image's
   *  alternative text and the source of the initials. */
  name: string;
  src?: string;
  className?: string;
};

export const initials = (name: string) =>
  name
    .trim()
    .split(/\s+/)
    .slice(0, 2)
    .map((word) => word[0] ?? "")
    .join("")
    .toUpperCase();

export function Avatar({ name, src, size, className }: AvatarProps) {
  return (
    <span className={cn(avatarVariants({ size }), className)} title={name}>
      {src ? (
        // A plain img keeps this portable to the Svelte twin; swap in the
        // framework's optimised image here if a product needs it.
        // eslint-disable-next-line @next/next/no-img-element
        <img className={styles.image} src={src} alt={name} />
      ) : (
        <>
          <span aria-hidden="true">{initials(name)}</span>
          <VisuallyHidden>{name}</VisuallyHidden>
        </>
      )}
    </span>
  );
}

components/display/avatar/avatar.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./avatar.module.css";

export const avatarVariants = cva(styles.root, {
  variants: { size: { sm: styles.sm, md: styles.md, lg: styles.lg } },
  defaultVariants: { size: "md" },
});

export type AvatarVariants = VariantProps<typeof avatarVariants>;

components/display/avatar/avatar.module.css

@layer primitive {
  .root {
    position: relative;
    display: inline-grid;
    flex: none;
    place-items: center;
    overflow: hidden;
    border: 1px solid var(--line);
    border-radius: var(--radius-pill);
    background: var(--accent-tint);
    color: var(--accent);
    font-weight: var(--weight-strong);
    user-select: none;
  }
  .sm {
    width: 20px;
    height: 20px;
    font-size: var(--text-9);
  }
  .md {
    width: 28px;
    height: 28px;
    font-size: var(--text-11);
  }
  .lg {
    width: 40px;
    height: 40px;
    font-size: var(--text-15);
  }
  .image {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
}

DescriptionList

terms and values

Workspace
Northstar
Plan
Team
Region
Europe (Ireland)
Created
12 March 2026
Sourcecomponents/display/description-list/doc.ts · components/display/description-list/description-list.tsx · components/display/description-list/description-list.module.css

components/display/description-list/doc.ts

/**
 * DescriptionList — terms and their values.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     DescriptionList   the list
 *     DescriptionItem   term, REQUIRED; children are the value
 *
 * # Behaviour
 *
 * R1  Each pair is one row: the term in `--ink-3`, the value in `--ink`,
 *     separated from the next pair by a `--line` rule.
 * R2  Below 480px the term stacks above its value.
 * R3  Semantically a description list, so readers announce each term with
 *     its value.
 */
export {};

components/display/description-list/description-list.tsx

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

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

export function DescriptionList({
  className,
  ...props
}: ComponentPropsWithRef<"dl">) {
  return <dl {...props} className={cn(styles.root, className)} />;
}

export type DescriptionItemProps = ComponentPropsWithRef<"div"> & {
  term: ReactNode;
};

/** One term and its value. A div around each pair is valid inside a dl and
 *  keeps the pair on one row. */
export function DescriptionItem({
  term,
  className,
  children,
  ...props
}: DescriptionItemProps) {
  return (
    <div {...props} className={cn(styles.item, className)}>
      <dt className={styles.term}>{term}</dt>
      <dd className={styles.value}>{children}</dd>
    </div>
  );
}

components/display/description-list/description-list.module.css

@layer primitive {
  .root {
    margin: 0;
  }
  .item {
    display: grid;
    grid-template-columns: minmax(8rem, 1fr) minmax(0, 2fr);
    gap: var(--space-7);
    padding-block: var(--space-5);
    border-bottom: 1px solid var(--line);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .item:last-child {
    border-bottom: 0;
  }
  .term {
    color: var(--ink-3);
  }
  .value {
    min-width: 0;
    margin: 0;
    color: var(--ink);
    overflow-wrap: anywhere;
  }
  @media (max-width: 480px) {
    .item {
      grid-template-columns: 1fr;
      gap: var(--space-2);
    }
  }
}

Empty

nothing here, said calmly

No members yet

Invite people by email. They join this workspace as members.

Sourcecomponents/display/empty/doc.ts · components/display/empty/empty.tsx · components/display/empty/empty.module.css

components/display/empty/doc.ts

/**
 * Empty — a successful answer of "nothing here".
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title      content, REQUIRED — what is absent, said plainly
 *     children?  one or two sentences on why, or what fills it
 *     action?    the thing to do about it
 *
 * # Behaviour
 *
 * R1  Centred, calm, in ink and muted tones. Never styled as an error: an
 *     empty list is a working system, and red teaches people it is broken.
 * R2  The body text is capped at 44 characters per line.
 */
export {};

components/display/empty/empty.tsx

import type { ReactNode } from "react";

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

export type EmptyProps = {
  /** What is absent, said plainly: "No members yet", not "No data". */
  title: ReactNode;
  children?: ReactNode;
  /** The thing to do about it, when there is one. */
  action?: ReactNode;
  className?: string;
};

/** A successful answer of "nothing here". Styled as calm content, never as an
 *  error: an empty list is a working system. */
export function Empty({ title, children, action, className }: EmptyProps) {
  return (
    <div className={cn(styles.root, className)}>
      <p className={styles.title}>{title}</p>
      {children ? <p className={styles.body}>{children}</p> : null}
      {action ? <div className={styles.action}>{action}</div> : null}
    </div>
  );
}

components/display/empty/empty.module.css

@layer primitive {
  .root {
    display: grid;
    justify-items: center;
    gap: var(--space-4);
    padding: var(--space-9) var(--space-7);
    color: var(--ink-3);
    text-align: center;
  }
  .title {
    margin: 0;
    color: var(--ink);
    font-size: var(--text-15);
    font-weight: var(--weight-strong);
  }
  .body {
    max-width: 44ch;
    margin: 0;
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .action {
    margin-top: var(--space-3);
  }
}