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.

Compound components in React

Turtlekit Editorial Team

The request came in on a Thursday: put the “Look up” button next to the VAT number, not under it. Everyone expected a one-line change. The field component on the billing settings page decided its own layout, though, and the only way to change that layout was another prop.

That component had started small, with a label, an input and an optional error. Then the VAT number needed a link in its hint and a button that fetches the company name from the registry. Each request arrived as a prop.

There is a better shape for components like this one, and it is the one tabs, menus and well-made form fields share: compound components. A parent shares state with its parts through context, so each page arranges the parts while the parent keeps them wired together.

Every prop is a layout decision made in advance

Here is the field’s API after three requests. Count the props that describe placement rather than data:

// Avoid: one prop per layout request
interface FormFieldProps {
  label: string;
  name: string;
  type?: "text" | "email";
  hint?: string;
  hintLinkHref?: string;
  hintLinkText?: string;
  error?: string;
  actionLabel?: string;
  onAction?: () => void;
}

The VAT number’s call site reads like a configuration file, and it still can’t put the button beside the input:

<FormField
  label="VAT number"
  name="vatNumber"
  hint="Starts with your country code."
  hintLinkHref="/help/vat"
  hintLinkText="Where to find it"
  error={error}
  actionLabel="Look up"
  onAction={() => window.alert("Looking up the company")}
/>

The cost spreads beyond one file. The author of the shipping form gave up on the component, wrote a raw <input> and forgot aria-describedby, so screen reader users on that form never hear the hint. When a component can’t express a layout, people route around it, and the accessibility wiring is the first thing lost on the way.

Let the parent own the wiring and the page own the layout

Split the field into parts that each render one element: Field.Label, Field.Hint, Field.Input and Field.Error. The parent, Field, holds only what the parts have to agree on: the ids that tie the label and the hint to the input, and whether the value is invalid. It shares them through context.

Fieldids · invalid · partscontextLabelinput idHinthint idInputids · invalidErrorerror id
Field owns the shared state; each part reads only what it needs. The page decides what sits between the parts.

The page writes the parts like HTML, in any order, with anything in between. The parent never sees the layout, and the layout never touches an id.

Building the field one part at a time

What follows is one file, field.tsx, in four fragments, and then the billing form that uses it.

A context that refuses to be misused

Start with what the parts share. The detail to notice is the guard in useField:

type Part = "hint" | "error";

interface FieldState {
  inputId: string;
  hintId: string;
  errorId: string;
  invalid: boolean;
  parts: ReadonlySet<Part>;
  register: (part: Part) => () => void;
}

const FieldContext = createContext<FieldState | null>(null);

function useField(part: string): FieldState {
  const field = use(FieldContext);
  if (!field)
    throw new Error(`<Field.${part}> must be inside a <Field>.`);
  return field;
}

Without it, a part rendered outside Field reads the context’s default, null, and fails a few lines later with an error that names neither the part nor the field. Fail on the first read instead, with the part’s name in the message.

Ids that match on the server and in the browser

The root creates its ids with useId, so the server’s HTML and the hydrated page agree, and keeps a registry of the parts on screen:

export function Field(props: {
  invalid?: boolean;
  children: ReactNode;
}) {
  const { invalid = false, children } = props;
  const id = useId();
  const [parts, setParts] = useState<ReadonlySet<Part>>(
    () => new Set(),
  );
  // Stable, so a part's effect runs once, not on every change to
  // `parts`.
  const register = useCallback((part: Part) => {
    setParts((current) => new Set(current).add(part));
    return () =>
      setParts(
        (current) => new Set([...current].filter((p) => p !== part)),
      );
  }, []);
  // One object per change, not per render: every part re-renders when
  // it changes.
  const value = useMemo<FieldState>(
    () => ({
      inputId: `${id}-input`,
      hintId: `${id}-hint`,
      errorId: `${id}-error`,
      invalid,
      parts,
      register,
    }),
    [id, invalid, parts, register],
  );
  return (
    <FieldContext value={value}>
      <div className="field">{children}</div>
    </FieldContext>
  );
}

register is created once. If it changed every time parts did, each part’s effect would rerun, register again and loop.

Parts that announce themselves

Each part reads only what it needs. The hint and the error register in an effect and leave in its cleanup:

function Label(props: ComponentProps<"label">) {
  const { inputId } = useField("Label");
  return <label {...props} htmlFor={inputId} />;
}

function Hint(props: ComponentProps<"p">) {
  const { hintId, register } = useField("Hint");
  useEffect(() => register("hint"), [register]);
  return <p {...props} id={hintId} />;
}

function ErrorMessage(props: ComponentProps<"p">) {
  const { errorId, invalid, register } = useField("Error");
  useEffect(
    () => (invalid ? register("error") : undefined),
    [invalid, register],
  );
  return invalid ? <p {...props} id={errorId} /> : null;
}

Because every part accepts its native element’s props, the page can add a class, a link or aria-live without the field growing a single prop.

An input that points only at what exists

The input builds its description from the registry rather than from guesses:

function Input(props: ComponentProps<"input">) {
  const { inputId, hintId, errorId, invalid, parts } =
    useField("Input");
  const describedBy = [
    parts.has("hint") ? hintId : null,
    parts.has("error") ? errorId : null,
  ].filter(Boolean);
  return (
    <input
      {...props}
      id={inputId}
      aria-invalid={invalid || undefined}
      aria-describedby={describedBy.join(" ") || undefined}
    />
  );
}

Follow the VAT input through its life, with r1 standing in for the id React generates:

The VAT input’s wiring, render by render
Momentpartsaria-describedby
First renderemptynone
After the hint’s effecthintr1-hint
After an invalid savehint, errorr1-hint r1-error

The input lists the hint before the error, the order the parts usually sit in. Remember the empty first render; it returns when the page is rendered on the server.

The form design asked for

The last lines of the file attach the parts to Field and export each one by name:

Field.Label = Label;
Field.Hint = Hint;
Field.Input = Input;
Field.Error = ErrorMessage;

// Named exports too, for code that imports only the parts it renders.
export { Label as FieldLabel, Hint as FieldHint };
export { Input as FieldInput, ErrorMessage as FieldError };

And the billing page can finally put the button where design wanted it:

// The billing page's details form, composed from the Field parts.
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { Field } from "./field";

const VAT_NUMBER = /^[A-Z]{2}[0-9A-Z]{8,12}$/;

export function BillingDetails({
  onLookUp,
}: {
  onLookUp: (vat: string) => void;
}) {
  const vatInput = useRef<HTMLInputElement>(null);
  const [invalid, setInvalid] = useState(false);

  // The inputs keep their own values: typing re-renders nothing above
  // them.
  function save(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const vat = String(
      new FormData(event.currentTarget).get("vatNumber"),
    );
    const wrong = !VAT_NUMBER.test(vat.toUpperCase());
    setInvalid(wrong);
    if (wrong) vatInput.current?.focus();
  }

  return (
    <form onSubmit={save} noValidate>
      <Field>
        <Field.Label>Billing email</Field.Label>
        <Field.Hint>Invoices and receipts go here.</Field.Hint>
        <Field.Input
          name="billingEmail"
          type="email"
          autoComplete="email"
        />
      </Field>
      <Field invalid={invalid}>
        <Field.Label>VAT number</Field.Label>
        <Field.Hint>
          Starts with your country code.{" "}
          <a href="/help/vat">Where to find it</a>
        </Field.Hint>
        <div className="field-row">
          <Field.Input
            ref={vatInput}
            name="vatNumber"
            autoComplete="off"
          />
          <button
            type="button"
            onClick={() => onLookUp(vatInput.current?.value ?? "")}
          >
            Look up
          </button>
        </div>
        <Field.Error>
          Enter the number as registered, such as DE123456789.
        </Field.Error>
      </Field>
      <button type="submit">Save billing details</button>
    </form>
  );
}

The inputs keep their own values, so typing re-renders nothing above them, and the button reads the VAT number through the ref that Field.Input passes on. Save with “DE12” and focus moves to the VAT input, now marked invalid with the error under the row. A screen reader reads the label, the hint, then the error. Thursday’s request has become a change to the page.

Deep dive

Why do the parts register themselves instead of the parent reading children? Children.map sees only the JSX it was handed, not what those components render. Wrap the VAT input in a div, or render the hint from a page’s own <VatHint>, and the part drops out of the walk. A part that registers works wherever it ends up.

The price of handing over the layout

Parts are not free, and most of what they cost shows up in places the happy path never visits.

Freedom has an API price

Parts buy layout freedom and cost API surface. There are more exports to learn, documentation of which parts exist, and rules no type can enforce, such as one input per field. Props keep the API closed, at the price of a release for every new layout. The deciding question is what actually varies:

Which shape fits
What variesShape
Nothing: one layout everywhereProps
The control and what sits beside itProps for the fixed parts, a child for the control
The order and the markup between partsCompound parts

Keep what changes on every keystroke out of the context

When a provider’s value changes, every component reading it re-renders, and memo doesn’t stop that. The root memoises its value so the parts re-render only when an id, the invalid flag or the registry changes, and the billing form leaves the typed values in the inputs. Put the value in the context instead and every keystroke re-renders every part of the field; lift it to the form’s state and it re-renders the whole form.

The wiring is the accessibility

Most of what Field does is accessibility work: htmlFor on the label, aria-describedby for the hint and the error, aria-invalid on the input. The error text matters as much as the wiring. Say how to fix the value, as WCAG’s error suggestion criterion asks, and on save move focus to the first invalid field or to a summary of the errors.

Tests that read the page like a screen reader

The useful test asserts what the parts promise together: the input is named by its label, described by its hint and error, and marked invalid. Role queries read names and descriptions the way assistive technology does, so these tests break when the wiring breaks and shrug when the markup changes:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { BillingDetails } from "./billing-details";
import { Field } from "./field";

test("each input is named and described by its own parts", () => {
  render(<BillingDetails onLookUp={() => {}} />);
  const email = screen.getByRole("textbox", {
    name: "Billing email",
  });
  expect(email).toHaveAccessibleDescription(
    "Invoices and receipts go here.",
  );
  expect(email).not.toBeInvalid();
});

test("a bad VAT number is marked and described", async () => {
  const user = userEvent.setup();
  render(<BillingDetails onLookUp={() => {}} />);
  const vat = screen.getByRole("textbox", { name: "VAT number" });
  await user.type(vat, "de12");
  await user.click(
    screen.getByRole("button", { name: "Save billing details" }),
  );
  expect(vat).toBeInvalid();
  expect(vat).toHaveFocus();
  expect(vat).toHaveAccessibleDescription(
    /^Starts with your country code\..*as registered/,
  );
});

test("a part outside its Field says what is wrong", () => {
  vi.spyOn(console, "error").mockImplementation(() => {});
  expect(() => render(<Field.Hint>Orphan</Field.Hint>)).toThrow(
    "<Field.Hint> must be inside a <Field>.",
  );
});

All three pass. Move a hint outside its field and the third test tells you why.

What the server sends first

field.tsx opens with "use client" because it holds state and context. A Server Component can still render Field and pass the parts as children, since JSX crosses the boundary. That is also why the parts are exported by name: server code imports exactly what it renders instead of reaching into a property of a client module.

Registration runs in effects, and effects don’t run on the server. The first HTML therefore arrives without aria-describedby, which appears with hydration. When the description has to be in the first HTML, pass the hint as a prop, which is the hybrid row in the table above.

React 19 drops two wrappers

React 19 renders the context itself as its provider, <FieldContext value={value}>, where React 18 needs <FieldContext.Provider value={value}>. use(FieldContext) reads it like useContext, and use may also run inside a condition. Field.Input takes a ref without forwardRef, because React 19 passes ref to function components as a prop and the spread hands it to the input.

The React Compiler memoises the context value for you. In new code, rely on it and drop the useMemo; in existing code, leave the manual memo in place until you have tested without it, as the Compiler’s guidance advises.

When props are still the better shape

A component with one layout everywhere gains nothing from parts. A settings row with a label, a description and a switch never changes order, and splitting it would only add exports to learn.

The field built here lets a page move every part, and some teams won’t want that much freedom. Label, hint, control and error is the order a screen reader needs, and a page that reorders it can end up with a field that reads its error first. If only the control and what sits beside it ever vary, take the label, the hint and the error as props and keep one children slot for the rest. That hybrid is where this field ends up in Turtlekit.

How Turtlekit’s Field and Tabs are built

Turtlekit’s Field takes the hybrid shape. Its label, hint and error are props, so their order and wiring are fixed and present in the first HTML. Its control is the child: TextInput, SelectControl or a switch reads its id and descriptions from the field’s context. Tabs is fully compound, with TabList, Tab and TabPanel sharing the selected value and each panel holding anything.

// The same billing settings with Turtlekit's Field and Tabs: the
// label, hint and error as props, and the input with its button in
// the child slot.
import {
  Button,
  Field,
  Inline,
  Tab,
  TabList,
  TabPanel,
  Tabs,
  TextInput,
} from "turtlekit";

interface BillingSettingsProps {
  vatError?: string;
  onLookUp: () => void;
}

export function BillingSettings({
  vatError,
  onLookUp,
}: BillingSettingsProps) {
  return (
    <Tabs defaultValue="details">
      <TabList aria-label="Billing settings">
        <Tab value="details">Details</Tab>
        <Tab value="invoices">Invoices</Tab>
      </TabList>
      <TabPanel value="details">
        <Field
          label="VAT number"
          hint="Starts with your country code."
          error={vatError}
        >
          <Inline gap="tight" wrap={false}>
            <TextInput name="vatNumber" autoComplete="off" />
            <Button variant="soft" onClick={onLookUp}>
              Look up
            </Button>
          </Inline>
        </Field>
      </TabPanel>
      <TabPanel value="invoices">{/* The invoice table */}</TabPanel>
    </Tabs>
  );
}

The “Look up” button sits beside the input in the child slot, which is all Thursday’s request needed. The tabs move with the arrow keys, Home and End, mirrored in right-to-left text, with nothing wired by hand.

Built on this pattern in Turtlekit: Field, Item, One-time code, Select, Slider, Tabs, Text area, Text field and Toolbar.

Three ways the wiring breaks

Three symptoms account for most of the trouble with a field like this one.

The hint isn’t read when the input is focused

Check that the hint renders inside the same Field as the input. If it does, look at the live page rather than the server HTML, because the description arrives with hydration.

Every field re-renders while someone types

Something above the fields holds the typed value: a provider wrapping the whole form, or the form’s own state. Give each field its own provider and let the inputs keep their values until save.

A Server Component can’t render Field.Label

Server code sees a client module only through its exports, and React stops it from reaching into a property such as Field.Label. Import FieldLabel and the other named parts instead.

Decide less, wire more

The instinct with a shared component is to make it cleverer, so every new request becomes one more thing it knows. The field that lasts knows less. It holds the few facts its parts must agree on, gets the accessibility right every time, and leaves the arrangement to the page that can see the design. Most of the work in a good compound component goes into deciding what the parent should never decide.

The variant pattern in React

When the parts are settled and the open question is how each one looks.

Optimistic UI updates with useOptimistic

When a change made in the form should show before the server confirms it.

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.