In one minute
Compound components give each role its own component (ToggleOn, ToggleOff). Slots go one step further. The root component publishes a map of props per slot name in context: { label: { htmlFor: id }, input: { id, … } }. Small generic pieces (Label, Input, Text) look up their slot and merge those props in. The same Label then works inside a form field, a toggle or a combobox, and the root still controls the accessibility wiring (ids, htmlFor, aria-describedby). The cost: slot names are strings, so a typo fails silently.
You’ll be able to: build a slot context with a useSlotProps hook, reuse one set of pieces across components, and explain the type-safety trade-off.
1function Field({ children }) {2 const id = useId()3 const slots = {4 label: { htmlFor: id },5 input: { id, 'aria-describedby': id + '-description' },6 description: { id: id + '-description' },7 }8 return <SlotContext value={slots}>{children}</SlotContext>9}1011<Field>12 <Label>Email for the receipt</Label>13 <div className="row"><Input type="email" /></div>14 <Text slot="description">We only use it for this order.</Text>15</Field>- render phase
const id = useId()Fieldmakes one id and builds a map of which props each slot gets. - render phase
label: { htmlFor: id },Labelreads the map from context and merges inhtmlFor. Its own props would win. - render phase
input: { id, 'aria-describedby': id + '-description' },Inputgetsidandaria-describedby, though it sits inside an extradiv. - render phase
description: { id: id + '-description' },Textis generic; itsslotprop says which entry to use. Recorded: clicking the label focused the input, and the description is linked.
1function Field({ children }) {2 const id = useId()3 const slots = {4 label: { htmlFor: id },5 input: { id, 'aria-describedby': id + '-description' },6 description: { id: id + '-description' },7 }8 return <SlotContext value={slots}>{children}</SlotContext>9}1011<Field>12 <Label>Email for the receipt</Label>13 <div className="row"><Input type="email" /></div>14 <Text slot="descripton">We only use it for this order.</Text>15</Field>- render phase
const slots = {The same map. - render phase
<Label>Email for the receipt</Label>Label and input are wired as before. - render phase
<Text slot="descripton">We only use it for this order.</Text>There’s no"descripton"entry, soTextgets nothing extra. No error, no warning. - commit phaseRecorded: the input still says
aria-describedby="_r_0_-description", but no element has that id. Screen readers lose the description.
Label, Input and Text pieces that pick up their props by slot name (both versions recorded).The example: checkout fields
At checkout there’s an email field with a description, and the gift-wrap toggle. Both need a label wired to their control.
1. The slot context and the pieces
type Slots = Record<string, Record<string, unknown>>
const SlotContext = createContext<Slots>({})
function useSlotProps<P extends { slot?: string }>(props: P, defaultSlot: string) {
const slots = use(SlotContext)
const { slot = defaultSlot, ...own } = props
return { ...slots[slot], ...own } // what you pass yourself wins
}
type WithSlot<T extends keyof React.JSX.IntrinsicElements> = ComponentProps<T> & { slot?: string }
function Label(props: WithSlot<'label'>) {
return <label {...useSlotProps(props, 'label')} />
}
function Input(props: WithSlot<'input'>) {
return <input {...useSlotProps(props, 'input')} />
}
function Text(props: WithSlot<'span'>) {
return <span {...useSlotProps(props, 'text')} />
}
function SlotSwitch(props: { slot?: string }) {
const { on = false, ...rest } = useSlotProps(props, 'switch') as { on?: boolean }
return <Switch on={on} {...rest} />
}
2. Two roots, one set of pieces
function Field({ children }: { children: ReactNode }) {
const id = useId()
const descriptionId = `${id}-description`
const slots = {
label: { htmlFor: id },
input: { id, 'aria-describedby': descriptionId },
description: { id: descriptionId },
}
return <SlotContext value={slots}>{children}</SlotContext>
}
function GiftToggle({ children }: { children: ReactNode }) {
const [on, setOn] = useState(false)
const id = useId()
const slots = {
label: { htmlFor: id },
switch: { id, on, onClick: () => setOn(!on) },
onText: { hidden: !on },
offText: { hidden: on },
}
return <SlotContext value={slots}>{children}</SlotContext>
}
3. What the consumer writes
<Field>
<Label>Email for the receipt</Label>
<div className="row">
<Input type="email" />
</div>
<Text slot="description">We only use it for this order.</Text>
</Field>
<GiftToggle>
<Label>Gift wrap</Label>
<SlotSwitch />
<Text slot="onText">Wrapped in recycled paper 🎁</Text>
<Text slot="offText">No gift wrap</Text>
</GiftToggle>
The email field’s wiring, recorded from the DOM:
{
"label for": "_r_0_",
"input id": "_r_0_",
"input aria-describedby": "_r_0_-description",
"description id": "_r_0_-description",
"aria-describedby points to": "We only use it for this order.",
"label for === input id": true
}
Clicking the label focused the input, although the input sits inside an extra div:
input#_r_0_
In the gift-wrap toggle, the same Label component got the switch’s id instead (label for === switch id was true). The two Texts, before and after clicking the switch:
Wrapped in recycled paper 🎁 (hidden)
No gift wrap
Wrapped in recycled paper 🎁
No gift wrap (hidden)
4. A typo in a slot name
<Field>
<Label>Email for the receipt</Label>
<Input type="email" />
<Text slot="descripton">We only use it for this order.</Text>
</Field>
{
"label for": "_r_0_",
"input id": "_r_0_",
"input aria-describedby": "_r_0_-description",
"description id": null,
"aria-describedby points to": "(nothing)",
"label for === input id": true
}
No error, no warning. The input still says it’s described by an element that doesn’t exist, so screen readers lose the description.
How it works
- Context instead of tree walking. Like compound components with context, any piece below the root finds the map, at any depth and in any order.
- Merge order: slot first, own props last.
{ ...slots[slot], ...own }lets the consumer override anything the slot provides, such as passing their ownid. - A default slot per piece.
Labeldefaults to"label", so you only writeslot="…"on generic pieces likeTextthat play different roles. - The root owns accessibility. The consumer arranges the pieces; the root decides which
idgoes where, so the wiring can’t be forgotten. - In production: React Aria Components work this way (
<Text slot="description">), with their own slot contexts and dev warnings.
Common mistakes
- Expecting type safety on slot names. A string slot isn’t checked by TypeScript (recorded: a typo silently dropped the description’s id). Libraries add runtime warnings for unknown slots.
- Overriding wiring by accident. Since your props win, passing
idto anInputbreaks the label link unless you mean it. - Using slots where a fixed structure is fine. For a component whose pieces never move, separate compound components are simpler and typo-proof.
Interview Q&A
What is the slots pattern?
A root component provides a context mapping slot names to props; generic child components read their slot’s props and merge them into their own. One Label or Text component can fill different roles in different parents.
How is it different from compound components?
Compound components usually have one component per role (ToggleOn, ToggleOff). Slots share generic pieces across many components, chosen by slot name, so Label is written once for a field, a toggle and a combobox.
Why does the merge order matter?
{ ...slotProps, ...ownProps } lets explicit props override the slot’s defaults. The reverse would make the consumer unable to customize anything the root sets.
What's the main downside?
Weak type safety. Slot names are strings, and a typo gives no error. Recorded: slot="descripton" left aria-describedby pointing at nothing.
Where is this used for real?
React Aria Components: Label, Input, Text slot="description", Button are reused across TextField, ComboBox, NumberField and more, each root providing its own slot props.
Related
- Compound Components: the one-component-per-role version.
- The useId Hook: the ids the roots generate.
- Context with use: how the slot map reaches the pieces.
Sources
- React Aria: Advanced customization (slots and contexts)
- Sandro Roth: Building component slots in React
- Topic order inspired by Kent C. Dodds’ EpicReact Advanced React Patterns workshop; the example app and code here are this handbook’s own.
Primary source: react-aria.adobe.com/customization