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 polymorphic as prop in React

Turtlekit Editorial Team

The accessibility review of the invoices page came back with two findings, both small. VoiceOver read the invoices one by one but never said “list, 3 items”, so nobody using it knew how many there were. And the search had no landmark, so there was no way to jump to it.

Both had the same cause. The page was built from Stack, the team’s layout column, and Stack always rendered a div. Wrapping it in a ul or putting a form inside it adds an element the column’s spacing doesn’t reach, so the page had chosen the spacing over the semantics.

The usual answer is a polymorphic as prop: one component that renders the element the call asks for, <Stack as="ul">, and keeps its own styling and behaviour. Done loosely, it lets through every mistake the types used to catch. Done well, the element decides which props the call may pass.

An as prop that accepts anything

The first polymorphic Stack took the shortest route to compiling. Any element type, and any prop at all:

// Avoid: any element, and any prop at all
interface StackProps {
  as?: ElementType;
  gap?: "tight" | "stack" | "group";
  children?: ReactNode;
  [prop: string]: unknown;
}

export function Stack({
  as: Element = "div",
  gap = "stack",
  ...rest
}: StackProps) {
  return <Element {...rest} className="stack" data-gap={gap} />;
}

It fixed the review’s findings within the hour. A week later, these two calls passed review as well, because they type-check:

// Both type-check: a link with a misspelt href, and a column that
// has become a button with none of a button's styles.
<Stack gap="tight">
  <Stack as="a" hreff="/invoices/INV-1041">
    INV-1041
  </Stack>
  <Stack as="button" onClick={open}>
    Northwind Traders
  </Stack>
</Stack>

The first is a link with a misspelt href, which renders as an anchor that goes nowhere. The second is a real button dressed as a layout column, with no focus style and no hover or pressed state. Its type defaults to submit, so inside a form it sends the form. The index signature that made as easy to add also switched off every check on what comes with it, so the column became a way to write any element without any of its rules.

The element decides the props

Make as a type parameter instead of any ElementType. The call’s value picks the element, and the props, the events and the ref are read from that element’s own type. Then close the list of elements to the ones a layout column can honestly be.

as="form"onSubmit · noValidateref: HTMLFormElementas="ul"list attributes · no hrefref: HTMLUListElementas="button"refused at compile timenot a StackElement
The value of as picks the element, and the element decides which props, events and ref the call may use. A value outside the list is refused before anything renders.

Let the element decide the props, and let the component decide the elements. Each half removes one of the calls above: the misspelt prop exists on no element, and a button is not on the list.

Building a Stack whose types follow its element

The typed column is one file, stack.tsx, in three parts. Then a file the compiler reads to prove what it refuses, and the invoices page.

A closed list of elements

// What a layout column can be: containers, landmarks, lists and the
// form, never a control.
export type StackElement =
  "div" | "section" | "article" | "nav" | "ul" | "ol" | "li" | "form";

Containers, landmarks, lists and the form. A button and a link are missing on purpose: they bring focus, keyboard behaviour and a role, which a layout column has no business inventing.

Props read from the chosen element

interface StackOwnProps {
  gap?: "tight" | "stack" | "group";
}

export type StackProps<T extends StackElement> = StackOwnProps & {
  as?: T;
} & Omit<ComponentProps<T>, keyof StackOwnProps | "as" | "className">;

T is the element the call chose, and ComponentProps<T> is everything that element accepts, its ref included. The column’s own props come first, and Omit keeps the element from redefining gap, as or the class the column sets itself.

One generic component

// React 19 passes ref as a prop, so it arrives in rest, typed for T.
export function Stack<T extends StackElement = "div">(
  props: StackProps<T>,
) {
  const { as, gap = "stack", ...rest } = props;
  // Inside, any element in the list: one prop type can't fit them
  // all.
  const Element = (as ?? "div") as ElementType;
  return <Element {...rest} className="stack" data-gap={gap} />;
}

The body is ordinary. The type parameter does its work at each call, where TypeScript infers T from the literal passed to as, and falls back to div when there is none. Inside, the element is a plain ElementType, because no single prop type fits every element in the list.

What the compiler now refuses

The refusals are worth keeping as code. Each line below carries @ts-expect-error, so the build fails if the error ever goes away:

// What the typed Stack refuses. Each call below is a type error, and
// the directive above it fails the type check if the error ever goes
// away.
import { Stack } from "./stack";
import type { StackElement } from "./stack";

const kind: StackElement = "form";

export function Rejected({ pay }: { pay: () => void }) {
  return (
    <Stack>
      {/* @ts-expect-error: a layout column is never a control */}
      <Stack as="button" onClick={pay} />
      {/* @ts-expect-error: a list has no href */}
      <Stack as="ul" href="/invoices" />
      {/* @ts-expect-error: a misspelt prop is refused */}
      <Stack as="form" acton="/invoices/search" />
      {/* @ts-expect-error: the whole list has no noValidate */}
      <Stack as={kind} noValidate />
    </Stack>
  );
}

The invoices page

With the typed column, the page can say what each part of it is:

"use client";
// The invoices page: a real list for screen readers, a search form
// whose submit event and ref are a form's, and a section named by its
// heading.
import { useId, useRef } from "react";
import type { FormEvent } from "react";
import type { Invoice } from "./invoices";
import { Stack } from "./stack";

interface InvoiceListProps {
  invoices: readonly Invoice[];
  onSearch: (query: string) => void;
}

export function InvoiceList({
  invoices,
  onSearch,
}: InvoiceListProps) {
  const id = useId();
  const search = useRef<HTMLFormElement>(null);

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    onSearch(String(new FormData(event.currentTarget).get("query")));
  }

  function clear() {
    search.current?.reset();
    onSearch("");
  }

  return (
    <Stack as="section" gap="group" aria-labelledby={`${id}-heading`}>
      <h2 id={`${id}-heading`}>Invoices</h2>
      <Stack
        as="form"
        ref={search}
        role="search"
        gap="tight"
        onSubmit={submit}
      >
        <label htmlFor={`${id}-query`}>Search invoices</label>
        <input id={`${id}-query`} name="query" type="search" />
        <button type="submit">Search</button>
        <button type="button" onClick={clear}>
          Clear
        </button>
      </Stack>
      {/* Keeps list semantics without markers. */}
      <Stack as="ul" role="list" gap="tight">
        {invoices.map((invoice) => (
          <Stack as="li" key={invoice.id} gap="tight">
            <a href={`/invoices/${invoice.id}`}>{invoice.id}</a>
            <span>{invoice.customer}</span>
          </Stack>
        ))}
      </Stack>
    </Stack>
  );
}

VoiceOver now reads “list, 3 items” before the first invoice, and the search is a landmark it can jump to. The form’s onSubmit gets a form event, so event.currentTarget feeds FormData without a cast, and the ref is an HTMLFormElement, so Clear calls reset() on it. One gap rule spaces every column on the page.

Deep dive

Why not accept any component as well, so <Stack as={RouterLink}> works? The type parameter would then range over every component in the app, and the props would be whatever that component takes, merged with the column’s own. The errors grow long and hard to read. Worse, the column now promises to pass its props through to a component it knows nothing about. Most of what people want from that call is a router link, and a provider at the top answers it once, as the Turtlekit section shows.

What a closed list costs and what it keeps

The typed column is stricter than most, and the strictness is the point. It still has edges worth knowing.

The list is a decision about responsibility

Every element added to the list is a promise the column makes about it. A section costs nothing. A button would mean focus styles, a type default and disabled behaviour, all inside a component meant for spacing. When a team asks for a new element, the question is whether the column can keep that element’s promises, not whether TypeScript allows it.

The element is the accessibility

Polymorphism exists here for accessibility. The ul gives the list its count, the section named by its heading becomes a region, and role="search" on the form makes a landmark. One trap remains: Safari’s VoiceOver stops announcing a list whose markers are removed with CSS, which is why the list also carries role="list".

Tests that ask for the elements

The test reads the page by role, so it checks the elements the calls asked for rather than the class names the column adds:

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceList } from "./invoice-list";
import { INVOICES } from "./invoices";

test("the invoices are a named region with a list of three", () => {
  render(<InvoiceList invoices={INVOICES} onSearch={() => {}} />);
  const section = screen.getByRole("region", { name: "Invoices" });
  const list = within(section).getByRole("list");
  expect(within(list).getAllByRole("listitem")).toHaveLength(3);
  expect(
    within(list).getByRole("link", { name: "INV-1042" }),
  ).toHaveAttribute("href", "/invoices/INV-1042");
});

test("the search form submits the query and resets", async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();
  render(<InvoiceList invoices={INVOICES} onSearch={onSearch} />);
  const search = screen.getByRole("search");
  const query = within(search).getByRole("searchbox", {
    name: "Search invoices",
  });
  await user.type(query, "Brightline");
  await user.click(
    within(search).getByRole("button", { name: "Search" }),
  );
  expect(onSearch).toHaveBeenLastCalledWith("Brightline");
  await user.click(
    within(search).getByRole("button", { name: "Clear" }),
  );
  expect(query).toHaveValue("");
  expect(onSearch).toHaveBeenLastCalledWith("");
});

Both pass. The type tests sit in rejected.tsx, which the type check runs with the rest of the site, so a loosened type fails the build before any test runs.

Server Components

stack.tsx holds no state and no effects, so it needs no "use client" and renders in a Server Component as it is. What can’t cross from the server is an event handler or a ref. The invoices page has both, so it is a client component, while a server-rendered page can still use <Stack as="ul"> for its static lists.

React 19 removes the hardest part

In React 19, ref is a prop for function components, so it travels in rest with the type the element gives it. In React 18 the component needs forwardRef, which loses the type parameter, and libraries restore it with a cast on the exported component. The React Compiler has nothing to add: the column computes nothing worth memoising.

When as is the wrong question

Use as where the element changes the meaning and nothing else: a container that could be a section, a list or a form. When the element changes the behaviour, as between a button and a link, let a prop that matters pick the element. An href makes a link, and the other props change with it.

A component with one honest element, such as a table or a dialog, needs no as at all. When a page needs a component of its own in place of the element, use a slot that passes the component’s props to its single child. It is easier to type than an as that takes anything.

How Turtlekit’s Stack, Heading and Button choose their element

Turtlekit’s Stack and Inline take as from a closed list of containers, landmarks, lists and the form, and their attributes, events and ref follow the element, so as="form" takes noValidate and its onSubmit is a form’s. Like every kit component they take no className or style. Heading requires its as, h1 to h6, and sets its look with a separate variant, so the outline and the size never pull against each other.

// The invoices page with Turtlekit: Stack and Inline take `as` from a
// closed set, Heading takes its level, and Button takes no `as` at
// all.
import { useId } from "react";
import type { FormEvent } from "react";
import {
  Button,
  Heading,
  Inline,
  Link,
  Stack,
  Text,
  TextField,
} from "turtlekit";
import type { Invoice } from "./invoices";

interface InvoicesProps {
  invoices: readonly Invoice[];
  onSearch: (query: string) => void;
}

export function Invoices({ invoices, onSearch }: InvoicesProps) {
  const heading = useId();

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    onSearch(String(new FormData(event.currentTarget).get("query")));
  }

  return (
    <Stack as="section" gap="intro" aria-labelledby={heading}>
      <Inline justify="between">
        <Heading as="h2" variant="section" id={heading}>
          Invoices
        </Heading>
        {/* href makes it a link; it keeps the look of a button. */}
        <Button href="/invoices/new">New invoice</Button>
      </Inline>
      <Inline as="form" role="search" align="end" onSubmit={submit}>
        <TextField
          label="Search invoices"
          name="query"
          type="search"
        />
        <Button type="submit" variant="soft">
          Search
        </Button>
      </Inline>
      <Stack as="ul" role="list" gap="items">
        {invoices.map((invoice) => (
          <Stack as="li" key={invoice.id} gap="heading">
            <Link href={`/invoices/${invoice.id}`}>{invoice.id}</Link>
            <Text as="p">{invoice.customer}</Text>
          </Stack>
        ))}
      </Stack>
    </Stack>
  );
}

Button takes no as. It renders a button, or a link when it has an href, and its props change with it: target belongs to the link alone. For router links, Root takes the router’s link component once, and every Button and Link with an href uses it.

Built on this pattern in Turtlekit: Layout.

When the types stop following the element

TypeScript says a prop doesn’t exist on the Stack

It doesn’t exist on the element you chose. An href on as="ul" is the common case: the link belongs on an a inside the list.

A form attribute is refused when as comes from a variable

as came from a variable typed with the whole list, so T is the whole list too, and only the props every element shares are left. noValidate belongs to a form alone, so it is refused. Pass a literal, or type the variable with the one element it holds.

The list lost its count in Safari

Its markers are removed with CSS. Keep role="list" on the ul or ol, as the invoices page does.

Choose the element, and let it bring its rules

An as prop is often sold as flexibility, a way to render anything. The column built here is useful for the opposite reason. It renders a short list of elements, each with its own props and its own ref, and the compiler refuses the rest. The invoices page reads as a list, a search and a region because the calls say so, and a layout column can no longer become a button by accident.

Ref as a prop and ref cleanup in React 19

When the question is the ref the component passes on, and how React 19 passes it.

The variant pattern in React

When the choice is how a component looks, not which element it is.

Headless components and the provider pattern in React

When the markup itself belongs to each screen, and only the behaviour is shared.

Compound components in React

When a component is several elements that share state, not one with a choice.