Headless components: behaviour and ARIA without markup
The library owns state, keys and ARIA; you own every element, and your props go through its getter.
Predict first
Before you read: predict
ExamHall's proctor console picks a room with downshift's useSelect. Development build, no StrictMode.
The proctor clicks the button.
What happens?
function RoomPicker() { const { isOpen, selectedItem, getToggleButtonProps, getMenuProps, getItemProps } = useSelect({ items: ['Room 1', 'Room 2', 'Room 3'] }); return ( <div> <button {...getToggleButtonProps({ onClick: () => track('room_picker') })}> {selectedItem ?? 'Choose room'} </button> <ul {...getMenuProps()}> {isOpen && ['Room 1', 'Room 2', 'Room 3'].map((room, index) => ( <li key={room} {...getItemProps({ item: room, index })}>{room}</li> ))} </ul> </div> );}The 30-second version
A headless library ships behaviour, state and ARIA, and renders nothing. You render every element, with your styles, and spread the library's props onto it. You inherit correct behaviour but own all markup, so it pays when one core is styled many ways.
The library owns state, keys and ARIA; you own every element, and your props go through its getter.
How it works
Step 1 of 10
In the kitchen
- A flight controller board: stabilisation, failsafe and receiver logic, with no frame, arms or canopy → A headless hook: state, keys and ARIA, no markup (step 1)
Analogy: A flight controller in a builder's own drone frame
| At the bench | In React |
|---|---|
| A flight controller board: stabilisation, failsafe and receiver logic, with no frame, arms or canopy | A headless hook: state, keys and ARIA, no markup (step 1) |
| The board's pads, printed "M1", "LED", "RX" | Prop getters: the props for each element you render (step 3) |
| An LED strip soldered to the board's LED pad | Your handler passed into the getter (step 4) |
| An arm whose motor lead was never soldered to a pad: it stays still while the others spin | Props that never reach the element (step 6) |
| The same board in a racing quad, a camera drone or a six-arm frame | One core, many markups (step 9) |
In real terms: the hook decides what happens; you decide what renders, and your props travel through its getter.
Breaks down when: a board is fixed hardware; a hook also changes its own state as you use it (step 8).
Contrasting case: a ready-to-fly drone with its own sealed body. That is a pre-built component: its props and theme set the look, and its markup is not yours to change.
Examples, run for real
4.1 ExamHall EXH-4140 and MR !4440: one table, two layouts
The proctor console lists a sitting's attempts as table rows on desktop and as right-to-left cards on tablets. Each layout built its own TanStack Table instance (EXH-4140). MR !4440 creates one instance; both layouts render its rows with ExamHall's own cells.
4.1 EXH-4140 a table per layout: sort by score on desktop, switch to tablet -> cards 62, 98, 71
4.1 MR !4440 one table instance: same steps -> cards 98, 91, 88 · desktop rows 98, 91, 88
The sort lived in the desktop's instance, so the tablet's cards were never sorted. With one instance, the state belongs to the hook and both markups read it (steps 8–9); right-to-left and dark mode stay in ExamHall's cells (step 1). The desktop rows are <tr> elements and the tablet cards <li dir="rtl"> items; neither layout holds sorting state of its own.
4.2 Payments ledger: one hook, two markups
A transfer form picks a currency with useSelect. The web form renders a <ul> of <li> rows; the right-to-left mobile form renders a grid of <span> chips. Both spread the same getters, and the same three keys are pressed on each.
4.2 web <ul>/<li>: ArrowDown, ArrowDown, Enter -> USD · 3 elements with role="option" (<li>)
4.2 rtl chip grid <span>: ArrowDown, ArrowDown, Enter -> USD · 3 elements with role="option" (<span>)
Neither markup wrote a key handler or a role: both came from the getters (steps 3, 9). The chip grid keeps its own class names (chips, grid, chip); the getters add no class or style.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A ledger's account filter on downshift's useSelect. An MR adds a keyboard shortcut: "/" jumps to the search box. Development build, no StrictMode.
QA reports that ArrowDown on the button no longer opens the account menu.
Which single change makes ArrowDown open the menu and keeps "/" working?
<button {...getToggleButtonProps()} onKeyDown={handleShortcut}> {selectedItem ?? 'Choose account'}</button>Question 2 of 7
While a payout is being submitted (busy), the payout-account menu must not open from the keyboard. Development build, no StrictMode.
busy is true.
Which line, in place of the comment, keeps the menu closed when the user presses ArrowDown?
<button {...getToggleButtonProps({ onKeyDown: (event) => { if (busy && event.key === 'ArrowDown') { /* the line */ } }, })}> {selectedItem ?? 'Choose account'}</button>Question 3 of 7
A merchant's statement on TanStack Table (@tanstack/table-core), with sorting and pagination registered and 3 rows a page. Its six payments, in the order the server sent them, are 40, 900, 300, 75, 620 and 210. The merchant is on page 2, which shows 75, 620 and 210. She sorts by amount, largest first:
Once the screen has settled, which page is shown, and which amounts?
table.setSorting([{ id: 'amount', desc: true }]);Question 4 of 7
A payments company needs a payout-account picker in two places. One tool is a pre-built picker: one element, <StyledPicker items={accounts} theme={…} />, with its own fixed <label>, <button>, <ul> and <li> markup and a theme that sets colours. The other is the team's headless picker on useSelect, in which the team writes each element itself.
- Admin screen: one internal screen. Check: ArrowDown, then Enter, selects the first account. The default look is accepted.
- Mobile sheet: the app's right-to-left bottom sheet. Checks: the same keys, every option inside an element with
role="dialog", and insidedir="rtl".
For each place, the team picks the tool that passes every check there with the fewest elements written in its own code.
Where does it use the headless picker?
Question 5 of 7
Write useFlagToggle({ defaultPressed }) from a blank file: a headless hook for ExamHall's "flag this question" toggle that renders nothing and returns { pressed, getToggleProps }. getToggleProps(props) returns the props to spread on any element: aria-pressed, every prop the caller passed in, and an onClick that runs the caller's onClick first, then flips pressed unless the caller called event.preventDefault(). The tests render it into a <button> and into a design-system Chip that forwards its props.
Export useFlagToggle.
Question 6 of 7
A transfer form's account menu on useSelect, with items set to ['Current', 'Savings', 'Escrow'], prefetches an account's balance when the mouse moves over it. An account on hold must not take the highlight from the mouse. Development build, no StrictMode.
onHold is ['Escrow']. The user opens the menu with a click, moves the mouse over Savings, then over Escrow, and presses Enter.
What does the button show?
<> <button {...getToggleButtonProps()}>{selectedItem ?? 'Choose account'}</button> <ul {...getMenuProps()}> {isOpen && items.map((item, index) => ( <li key={item} {...getItemProps({ item, index, onMouseMove: (event) => { prefetch(item); if (onHold.includes(item)) event.preventDownshiftDefault = true; }, })}> {item} </li> ))} </ul></>Question 7 of 7
A payout form on useSelect. The team kept its own label instead of calling getLabelProps(). Development build, no StrictMode.
In the rendered DOM, what does the button's aria-labelledby hold?
<label id="payout-label">Payout account</label><button {...getToggleButtonProps()}>{selectedItem ?? 'Choose account'}</button>Go deeper
Pro tips
- Pass your props through the library's prop getter, as in
getItemProps({ onClick }). When: every element you render from a headless hook. - Read the library's development warnings when you wrap its elements. Downshift says when a getter's
refnever reached a DOM element, the usual sign that a design-system component dropped it. When: putting a headless hook's props on your own components. Verify (RAN,tips.mjs): the second line below.
tip 1 K2 (an onClick passed into the getter runs and the toggle flips): reference passes · spread-variant.jsx fails
tip 2 menu inside a List that drops its ref: 1 warning: downshift: The ref prop "ref" from getMenuProps was not applied correctly on your element.
Source
- TanStack Table on GitHub (the row's first source), README read 2026-10-14: "A headless table library for building powerful datagrids with full control over markup, styles, and behavior." Step 1. Valid as of 2026-10-14.
- TanStack Table: Overview, read 2026-10-14 from the repository's
docs/overview.md: "Headless UI is a term for libraries and utilities that provide the logic, state, processing, and APIs for UI elements and interactions, but do not provide markup, styles, or pre-built implementations." and "With a pre-built table component, you pass props in and style the result from the outside, limited to whatever the component's props and theme APIs allow." Steps 1, 2, 10. Valid as of 2026-10-14. - React Aria (the row's second source), home page read 2026-10-14: "React Aria is style-free out of the box, allowing you to build custom designs to fit your application or design system using any styling and animation solution." and "Get complete control with hooks. Drop down to the low-level Hook-based API to intercept events, override behavior, customize DOM elements, and much more." Steps 2, 9. Valid as of 2026-10-14.
- downshift
useSelectREADME at v9.4.0 (named by no row; supports teaching only, every key here is executed), read 2026-10-14: "It's advisable to pass all your props to that function rather than applying them on the element yourself to avoid your props being overridden (or overriding the props returned)." and "If you would like to prevent the default handler behavior in some cases, you can set the event'spreventDownshiftDefaultproperty totrue". Steps 3–5. Valid as of 2026-10-14.
Related lessons
- Read first: Compound components: parts that share a Root's state
- Read first: Focus and keyboard management
- Related: Render props, higher-order components and custom hooks
- Related: Controlled and uncontrolled component APIs
- Related: Design systems and component APIs
- Related: UI build: sortable, filterable, paginated data table
- Related: Semantic HTML, the accessibility tree and ARIA
Checked against the tools and docs as of 2026-10-14.