Good to knowAdvanced React Patterns · 04

The Slots Pattern

A root component publishes props per slot name; generic Label, Input and Text pieces pick theirs up.

On this page · 7 sections
  1. In one minute
  2. The example: checkout fields
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

slot="description"
CheckoutEmail.tsx
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>
What you see
label for = ?
input id = ? · aria-describedby = ?
description id = ?
  1. render phaseconst id = useId()Field makes one id and builds a map of which props each slot gets.
  2. render phaselabel: { htmlFor: id },Label reads the map from context and merges in htmlFor. Its own props would win.
  3. render phaseinput: { id, 'aria-describedby': id + '-description' },Input gets id and aria-describedby, though it sits inside an extra div.
  4. render phasedescription: { id: id + '-description' },Text is generic; its slot prop says which entry to use. Recorded: clicking the label focused the input, and the description is linked.

0 / 0
A checkout email field built from generic 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

Common mistakes

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.

Sources

Primary source: react-aria.adobe.com/customization