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.
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.