Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

The variant pattern in React

Turtlekit Editorial Team

The screenshot came with a one-line question: which one is right? On the left, the invoice list showed INV-0047 as a green “paid” pill. On the right, the same invoice’s own page said PAID in grey capitals.

Both screens were right, as far as their authors knew. Each had decided what a status looks like, a month apart, and neither had a reason to look at the other.

The variant pattern takes that decision away from the screens. A component gets a closed set of named options, such as a tone or a size, in place of styling props. Call sites say what something means, and the component decides, once, how it looks.

Two screens, two answers to the same question

The invoice list came first. Its author picked the colours in a ternary:

// Avoid: the invoice list picks colours in a ternary
export function ListStatus({ status }: { status: string }) {
  const colour =
    status === "paid"
      ? "pill-green"
      : status === "overdue"
        ? "pill-red"
        : "pill-grey";
  return <span className={`pill ${colour}`}>{status}</span>;
}

A month later, the invoice page made its own choice:

// Avoid: the invoice page, written a month later, picks its own
export function HeaderStatus({ status }: { status: string }) {
  const colour =
    status === "overdue" ? "text-[#d92d20]" : "text-[#667085]";
  return <strong className={colour}>{status.toUpperCase()}</strong>;
}

Then “void” shipped, and each screen had to guess again. Dark mode followed, and the page’s hard-coded red sat on a near-black background at a contrast nobody had measured. None of this was careless work. Two places were making the same decision, and the codebase gave them no way to know.

A vocabulary instead of a paintbox

Give the badge a tone instead of colours: neutral, info, success, warning or danger. One stylesheet turns each tone into a look. The invoice keeps its own words, such as “overdue”, and a single map translates them into tones.

StatusToneLookpaidsentoverduedraftvoidsuccessPaidinfoSentdangerOverdueneutralDraft, Void
Status is data, tone is meaning, and the look is decided once per tone. Two statuses can share a tone; no screen picks a colour.

From a ternary to a typed vocabulary

Four fragments build the parts; the invoice table at the end puts them together.

Name the tones once

The tones are declared a single time, as an array and as a type derived from it:

export const TONES = [
  "neutral",
  "info",
  "success",
  "warning",
  "danger",
] as const;
export type Tone = (typeof TONES)[number];

Props accept the type, and anything that needs every tone, a test or a documentation page, iterates the array. Neither can drift from the other.

Let the stylesheet own the colours

The badge writes its tone into the HTML and does nothing else with it:

interface BadgeProps {
  /** What the status means; the colour follows from it. */
  tone?: Tone;
  /** "solid" for the one badge on a screen that must stand out. */
  variant?: "soft" | "solid";
  children: ReactNode;
}

export function Badge({
  tone = "neutral",
  variant = "soft",
  children,
}: BadgeProps) {
  return (
    <span className="badge" data-tone={tone} data-variant={variant}>
      {children}
    </span>
  );
}

One stylesheet decides what each tone looks like, and it uses theme roles rather than fixed colours:

/* One rule per tone, written once. The colours are theme roles,
   so dark mode changes the roles, never this file. */
.badge[data-tone="success"] {
  background: var(--color-success-subtle);
  color: var(--color-success-text);
}

.badge[data-tone="danger"] {
  background: var(--color-danger-subtle);
  color: var(--color-danger-text);
}

/* neutral, info and warning follow the same shape. */

/* Variant × tone: the one solid danger badge. */
.badge[data-variant="solid"][data-tone="danger"] {
  background: var(--color-danger-solid);
  color: var(--color-on-solid);
}

Deep dive

Data attributes or class names? Both work. Data attributes keep each axis a separate name and value, so one rule can combine them ([data-variant="solid"][data-tone="danger"]) without a class per combination, and the variant stays readable in tests and dev tools. In a utility-class codebase, generate the classes from the same closed types.

One map between the invoice and the badge

The invoice keeps its own vocabulary. One map translates it, and satisfies makes the translation exhaustive:

const STATUS = {
  draft: { label: "Draft", tone: "neutral" },
  sent: { label: "Sent", tone: "info" },
  paid: { label: "Paid", tone: "success" },
  overdue: { label: "Overdue", tone: "danger" },
  void: { label: "Void", tone: "neutral" },
} satisfies Record<InvoiceStatus, { label: string; tone: Tone }>;

export function InvoiceStatusBadge({
  status,
}: {
  status: InvoiceStatus;
}) {
  const { label, tone } = STATUS[status];
  return <Badge tone={tone}>{label}</Badge>;
}

satisfies checks the map against Record<InvoiceStatus, …> while keeping each entry’s literal type. Add "refunded" to InvoiceStatus and this file stops compiling until someone decides what a refund looks like. The question in the screenshot has become a compile error.

A button with three axes and defaults that follow

A button needs more than one choice: how loud it is, what it means and how big it is. Each axis is a union, and the props type leaves out className and style, so nobody steps outside the set by accident:

type Variant = "solid" | "soft" | "ghost";
type ButtonTone = "accent" | "neutral" | "danger";

interface ButtonProps extends Omit<
  ComponentProps<"button">,
  "className" | "style"
> {
  variant?: Variant;
  /**
   * Defaults from the variant: a ghost button is neutral, the rest
   * accent.
   */
  tone?: ButtonTone;
  size?: "1" | "2" | "3";
}

export function Button({
  variant = "soft",
  tone,
  size = "2",
  ...props
}: ButtonProps) {
  const resolvedTone =
    tone ?? (variant === "ghost" ? "neutral" : "accent");
  return (
    <button
      type="button"
      {...props}
      className="button"
      data-variant={variant}
      data-tone={resolvedTone}
      data-size={size}
    />
  );
}

A ghost button is quiet, so its tone defaults to neutral, and most call sites end up with one prop or none. Notice what the names describe. A tone called red would describe this year’s palette, and the day the brand moves to orange, tone="red" would be wrong in every file. Name the meaning, danger, or a step on a scale, size="3".

The table, assembled

// The invoice list: every status and action drawn from named
// variants.
import { Button } from "./button";
import { InvoiceStatusBadge } from "./invoice-status";
import type { InvoiceStatus } from "./invoice-status";

export interface Invoice {
  id: string;
  customer: string;
  total: string;
  status: InvoiceStatus;
}

interface InvoiceTableProps {
  invoices: readonly Invoice[];
  onRemind: (id: string) => void;
}

export function InvoiceTable({
  invoices,
  onRemind,
}: InvoiceTableProps) {
  return (
    <table>
      <caption>Invoices</caption>
      <thead>
        <tr>
          <th scope="col">Invoice</th>
          <th scope="col">Customer</th>
          <th scope="col">Total</th>
          <th scope="col">Status</th>
          <th scope="col">Actions</th>
        </tr>
      </thead>
      <tbody>
        {invoices.map((invoice) => (
          <tr key={invoice.id}>
            <th scope="row">{invoice.id}</th>
            <td>{invoice.customer}</td>
            <td className="numeric">{invoice.total}</td>
            <td>
              <InvoiceStatusBadge status={invoice.status} />
            </td>
            <td>
              {invoice.status === "overdue" ? (
                <Button
                  size="1"
                  aria-label={`Send reminder for ${invoice.id}`}
                  onClick={() => onRemind(invoice.id)}
                >
                  Send reminder
                </Button>
              ) : null}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Every status in the table now comes from the map, and only the overdue row offers a reminder. Change the danger colour in the stylesheet and every overdue badge in the app follows it.

Living inside a closed set

A closed set changes how a team works as much as how the code looks, and not all of it is comfortable.

Where the closed set starts to hurt

Some requests now wait. A screen that needs a look the set lacks has to ask for a variant, and the team decides once, for every screen. That friction is deliberate; an escape hatch such as className would be where the next drift comes in. Booleans, the shape most teams try first, fail in a different way:

Booleans or one named variant
ShapeWhat it allows
primary, danger and ghost as booleansEight combinations, most meaningless, such as primary and ghost at once
variant and tone as unionsOnly the combinations the design defines, each with a name

A thousand badges cost a thousand spans

A variant is a string in an attribute. Nothing is computed per render and no styles are generated at runtime. The stylesheet does grow with variants times tones, so write combined rules only for the combinations the design actually uses.

Colour is never the whole message

A tone is a colour, and colour alone can’t carry meaning, so the badge always says “Overdue” rather than showing a red dot. Contrast becomes a one-time check: each tone’s text against its background, in both themes, at 4.5:1. The repeated “Send reminder” button gets a name that says which invoice and still begins with its visible words, as label in name requires.

A test that fails at compile time

The behavioural tests check that each status shows its words in its tone and that actions appear only where the status calls for them. The last one is a type test: @ts-expect-error breaks the type check the day tone="red" starts compiling.

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { Badge } from "./badge";
import { InvoiceTable } from "./invoice-table";
import type { Invoice } from "./invoice-table";

const INVOICES: Invoice[] = [
  {
    id: "INV-0047",
    customer: "Priya Raman",
    total: "€1,200.00",
    status: "paid",
  },
  {
    id: "INV-0048",
    customer: "Tomás Ortega",
    total: "€640.00",
    status: "overdue",
  },
  {
    id: "INV-0049",
    customer: "Lena Fischer",
    total: "€95.00",
    status: "draft",
  },
];

test("each status shows its words in its tone", () => {
  render(<InvoiceTable invoices={INVOICES} onRemind={() => {}} />);
  const overdue = screen.getByRole("row", { name: /INV-0048/ });
  expect(within(overdue).getByText("Overdue")).toHaveAttribute(
    "data-tone",
    "danger",
  );
  const paid = screen.getByRole("row", { name: /INV-0047/ });
  expect(within(paid).getByText("Paid")).toHaveAttribute(
    "data-tone",
    "success",
  );
});

test("only the overdue invoice offers a reminder", async () => {
  const onRemind = vi.fn();
  render(<InvoiceTable invoices={INVOICES} onRemind={onRemind} />);
  const reminders = screen.getAllByRole("button", {
    name: /^Send reminder/,
  });
  expect(reminders).toHaveLength(1);
  await userEvent.click(
    screen.getByRole("button", {
      name: "Send reminder for INV-0048",
    }),
  );
  expect(onRemind).toHaveBeenCalledWith("INV-0048");
});

test("a colour is not a tone", () => {
  // @ts-expect-error: "red" is a look; tones describe meaning
  render(<Badge tone="red">Late</Badge>);
});

Nothing to hydrate

The badge and the map hold no state, effects or context, so they render as Server Components with no "use client". The variant is in the HTML as an attribute and the stylesheet styles it on the first paint. Nothing waits for JavaScript, and nothing shifts when it arrives.

React 19 and the Compiler change little here

React 19 passes ref to function components as a prop, and ComponentProps<"button"> includes it, so <Button ref={ref}> reaches the native button through the spread; React 18 needs forwardRef. The React Compiler has almost nothing to do, since the map is a module constant and the components only read props.

Values that should stay values

Some values are continuous: a progress bar’s width, a chart series coloured by its data, a brand colour a customer types in. Naming them would only round them off. Take them as values, and validate them.

A one-off, such as the illustration on a launch page, doesn’t need a vocabulary either. The time to name a variant is when a second screen needs the same look.

How Turtlekit’s Badge and Button are built

Turtlekit’s Badge takes a tone for a status (neutral, info, success, warning or danger), a variant of soft or solid, and a size. Its Button takes a variant (solid, soft, surface or ghost), a tone (accent, neutral or danger) that defaults from the variant, and a size from 1 to 3. Neither accepts className or style.

// An overdue invoice's status and actions with Turtlekit's Badge and
// Button.
import { Badge, Button, Inline } from "turtlekit";

export function OverdueInvoiceActions({ id }: { id: string }) {
  return (
    <Inline gap="tight">
      <Badge tone="danger">Overdue</Badge>
      <Button size="1" aria-label={`Send reminder for ${id}`}>
        Send reminder
      </Button>
      <Button
        size="1"
        variant="ghost"
        tone="danger"
        aria-label={`Void invoice ${id}`}
      >
        Void invoice
      </Button>
    </Inline>
  );
}

Each tone is drawn from theme roles checked for contrast in light and dark, and the badge requires its words, so a status can’t be a bare dot. Where a value really is open, the kit takes it as a value: a Root’s brand colour is a hex that Turtlekit turns into a full scale.

Built on this pattern in Turtlekit: Avatar, Badge, Button, Call to action, Callout, Card, Carousel, Data list, FAQ, Icon button, Icon tile, Image slider, Layout, Logo cloud, Media, Metric, Pagination, Progress, Root, Segmented control, Select, Spinner, Table, Tabs, Testimonials, Text field, Toggle and Typography.

Symptoms of a vocabulary with gaps

Each of these comes from a decision made outside the vocabulary.

A new status renders without a colour

The status reached the badge without going through the map, or the map is typed as a plain object. Type it with satisfies Record<Status, …> and send every status through it.

A tone reads well in light mode and poorly in dark

Its rule uses fixed colours. Point it at theme roles, such as a danger text colour defined per theme.

A screen needs a look the set doesn’t have

Add the variant to the component, named for what it means, and review it like any other API change. An override at the call site fixes one screen and restarts the drift.

Who decides what overdue looks like

The screenshot question had no good answer because nobody owned the decision it was asking about. The variant pattern gives that decision an owner, and the owner is a small map more than a badge: the place where the product’s language meets the design’s. A new status now has to be argued for there before it ships, and a customer is the last person who should find it first.

Compound components in React

When what varies is the layout of a component’s parts, not how one of them looks.

Optimistic UI updates with useOptimistic

When a status changes the moment someone acts, before the server agrees.

Single responsibility in React components

One reason to change per component, so a wording fix, an API change and a new action each land in one place.

Presentational and container components in React

Split how a list looks from what it knows, so the same component serves a server page and a picker dialog.