r.id} bordered />;
}
render( );
```
## Custom cells & actions
A column's `cell` is a **React render-prop** (never an HTML string) — put a
`Badge`, `Button`, icon, or anything else inside. Table still owns the ``
container (padding, alignment, sticky, a11y); `cell` only supplies the content.
```tsx
function CustomCellDemo() {
const data = [
{ id: '1', name: 'Ada Lovelace', role: 'admin' },
{ id: '2', name: 'Linus Torvalds', role: 'user' },
];
const columns = [
{ id: 'name', header: 'Name', accessor: 'name' },
{
id: 'role',
header: 'Role',
cell: ({ row }) => (
{row.original.role}
),
},
{
id: 'actions',
header: '',
align: 'end',
cell: ({ row }) => (
alert('Edit ' + row.original.name)}>
Edit
),
},
];
return r.id} />;
}
render( );
```
## Column filtering
A column's `filter` is **two-tier**: declarative presets for the common cases,
and a render escape hatch for everything else. Either way Table owns the
plumbing — the header trigger, the `Popover` (a real portal), the active-state
dot, and the value wiring to TanStack.
### Presets
For the common controls, pass a preset — a bare string for `text`, or an object
with `type` (+ `options` for the choice controls):
| `type` | Control | Filter value | Match |
| -------------- | ----------------- | ------------ | ---------- |
| `text` | `Input` | `string` | substring |
| `select` | `Select` (single) | `string` | equality |
| `radio` | `Radio` list | `string` | equality |
| `checkbox` | `Checkbox` list | `string[]` | membership |
| `multi-select` | `Checkbox` list¹ | `string[]` | membership |
```tsx
{ id: 'name', header: 'Name', accessor: 'name', filter: 'text' }
{ id: 'role', header: 'Role', accessor: 'role',
filter: { type: 'checkbox', options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
] } }
```
¹ Spar's `Select` has no multi mode yet, so `multi-select` currently renders
the same `Checkbox` list as `checkbox`.
```tsx
function FilterPresetDemo() {
const data = [
{ id: '1', name: 'Ada Lovelace', role: 'admin' },
{ id: '2', name: 'Linus Torvalds', role: 'user' },
{ id: '3', name: 'Grace Hopper', role: 'admin' },
{ id: '4', name: 'Alan Turing', role: 'user' },
];
const columns = [
// Bare string preset — the shortest form.
{ id: 'name', header: 'Name', accessor: 'name', filter: 'text' },
// Object preset — a choice control needs its options.
{
id: 'role',
header: 'Role',
accessor: 'role',
filter: { type: 'checkbox', options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
] },
},
];
return r.id} />;
}
render( );
```
### Custom filters (escape hatch)
When no preset fits — a number range, a date range, a combobox, async-loaded
options — pass `render` instead. It receives
`{ value, setValue, clear, column, close }` and returns any control; Table still
owns the surrounding popover. `value` is whatever shape your control writes, so
pair it with a `filterFn` (client mode) and, if the default non-empty heuristic
is wrong, an `isActive` predicate for the active dot.
Why both tiers?
The legacy takeoff-ui filter was a closed `type` enum, which could never
cover every column's needs, so customization ended up blocked on a library
change. Every mature table (Mantine/Material React Table, AG Grid, MUI X)
instead layers a short preset under a render escape hatch. Presets keep
the 90% case one-line; `render` (the same inversion as `cell` /
`expansion.render`) leaves the rest fully open.
```tsx
function FilterCustomDemo() {
const data = [
{ id: '1', name: 'Ada Lovelace', score: 91 },
{ id: '2', name: 'Linus Torvalds', score: 64 },
{ id: '3', name: 'Grace Hopper', score: 88 },
{ id: '4', name: 'Alan Turing', score: 73 },
];
const columns = [
{ id: 'name', header: 'Name', accessor: 'name', filter: 'text' },
{
id: 'score',
header: 'Score',
accessor: 'score',
align: 'end',
// Escape hatch: a "minimum score" filter no preset covers. You render
// the control + supply the matching predicate; Table owns the popover.
filter: {
isActive: (value) => value != null && value !== '',
filterFn: (row, columnId, value) =>
value == null || value === '' ? true : row.getValue(columnId) >= Number(value),
render: ({ value, setValue }) => (
setValue(e.target.value || undefined)}
/>
),
},
},
];
return r.id} />;
}
render( );
```
## Expandable rows
Expansion has **two mutually exclusive shapes**. The first is a **detail
panel**: pass `expansion.render` to render a disclosure row beneath each row.
The second is **tree data** (`getSubRows`), covered below — supplying both keeps
tree mode and suppresses the detail panel.
```tsx
function ExpansionDemo() {
const data = [
{ id: '1', name: 'Ada Lovelace', note: 'First programmer; worked on the Analytical Engine.' },
{ id: '2', name: 'Grace Hopper', note: 'Invented the first compiler; coined "debugging".' },
];
const columns = [{ id: 'name', header: 'Name', accessor: 'name' }];
return (
r.id}
expansion={{ render: (row) => {row.note}
}}
/>
);
}
render( );
```
## Tree data (sub-rows)
For hierarchical data, pass `getSubRows` to read each row's children and an
`expansion` config (without `render`) to make the expand toggle reveal those
flattened sub-rows instead of a detail panel. The toggle appears only on rows
that actually have children; `row.depth` is available inside a `cell` to indent
nested rows. Here `expansion={{ defaultValue: true }}` expands everything on
mount.
```tsx
function TreeDemo() {
const data = [
{
id: 'eng', name: 'Engineering', headcount: 42,
children: [
{ id: 'eng-fe', name: 'Frontend', headcount: 18 },
{ id: 'eng-be', name: 'Backend', headcount: 24 },
],
},
{
id: 'res', name: 'Research', headcount: 15,
children: [
{ id: 'res-ml', name: 'Machine Learning', headcount: 9 },
{ id: 'res-hci', name: 'Human–Computer Interaction', headcount: 6 },
],
},
];
const columns = [
{
id: 'name',
header: 'Team',
accessor: 'name',
// Indent by depth so the tree reads as a hierarchy.
cell: ({ row }) => (
{row.original.name}
),
},
{ id: 'headcount', header: 'Headcount', accessor: 'headcount', align: 'end' },
];
return (
r.id}
getSubRows={(row) => row.children}
expansion={{ defaultValue: true }}
bordered
/>
);
}
render( );
```
## Sticky columns & header
Pin columns to the left or right edge with `sticky: 'left' | 'right'` on a
column def. Add `stickyHeader` to also pin the header row during vertical
scroll. Pass `maxHeight` to the viewport via `slotProps.tableViewport` to enable
vertical scroll — sticky header only activates when the viewport itself scrolls,
not a parent container. The example below constrains the wrapper to `720px` wide
and the viewport to `280px` tall so both axes scroll.
```tsx
function StickyDemo() {
const data = [
{ id: '1', name: 'Ada Lovelace', dept: 'Engineering', role: 'admin', city: 'London', country: 'UK', age: 36, salary: 92000, status: 'active' },
{ id: '2', name: 'Linus Torvalds', dept: 'Engineering', role: 'user', city: 'Helsinki', country: 'FI', age: 35, salary: 88000, status: 'active' },
{ id: '3', name: 'Grace Hopper', dept: 'Research', role: 'admin', city: 'New York', country: 'US', age: 45, salary: 97000, status: 'active' },
{ id: '4', name: 'Alan Turing', dept: 'Research', role: 'user', city: 'London', country: 'UK', age: 41, salary: 85000, status: 'inactive' },
{ id: '5', name: 'Margaret Hamilton', dept: 'Engineering', role: 'admin', city: 'Boston', country: 'US', age: 33, salary: 99000, status: 'active' },
{ id: '6', name: 'Dennis Ritchie', dept: 'Engineering', role: 'user', city: 'Bronxville', country: 'US', age: 48, salary: 91000, status: 'active' },
{ id: '7', name: 'Barbara Liskov', dept: 'Research', role: 'admin', city: 'Cambridge', country: 'US', age: 52, salary: 103000, status: 'active' },
{ id: '8', name: 'Ken Thompson', dept: 'Engineering', role: 'user', city: 'San Jose', country: 'US', age: 50, salary: 89000, status: 'inactive' },
{ id: '9', name: 'Bjarne Stroustrup', dept: 'Engineering', role: 'user', city: 'Aarhus', country: 'DK', age: 47, salary: 87000, status: 'active' },
{ id: '10', name: 'Guido van Rossum', dept: 'Research', role: 'admin', city: 'Amsterdam', country: 'NL', age: 44, salary: 95000, status: 'active' },
{ id: '11', name: 'James Gosling', dept: 'Engineering', role: 'user', city: 'Calgary', country: 'CA', age: 49, salary: 86000, status: 'inactive' },
{ id: '12', name: 'Tim Berners-Lee', dept: 'Research', role: 'admin', city: 'London', country: 'UK', age: 43, salary: 101000, status: 'active' },
];
const columns = [
{ id: 'name', header: 'Name', accessor: 'name', sticky: 'left', sortable: true, width: 180 },
{ id: 'dept', header: 'Department', accessor: 'dept', sortable: true, width: 140,
filter: { type: 'checkbox', options: [
{ label: 'Engineering', value: 'Engineering' },
{ label: 'Research', value: 'Research' },
]},
},
{ id: 'role', header: 'Role', accessor: 'role', width: 100,
filter: { type: 'radio', options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
]},
cell: ({ row }) => (
{row.original.role}
),
},
{ id: 'city', header: 'City', accessor: 'city', width: 130 },
{ id: 'country', header: 'Country', accessor: 'country', width: 100, align: 'center' },
{ id: 'age', header: 'Age', accessor: 'age', width: 80, align: 'end', sortable: true },
{ id: 'salary', header: 'Salary', accessor: 'salary', width: 110, align: 'end', sortable: true,
cell: ({ row }) => '$' + row.original.salary.toLocaleString() },
{ id: 'status', header: 'Status', accessor: 'status', width: 110,
filter: { type: 'checkbox', options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]},
cell: ({ row }) => (
{row.original.status}
),
},
{ id: 'actions', header: '', align: 'end', sticky: 'right', width: 80,
cell: ({ row }) => (
alert('Edit ' + row.original.name)}>
Edit
),
},
];
return (
r.id}
sorting={{ multi: true }}
selection={{ mode: 'multiple' }}
pagination={{ pageSize: 6, pageSizeOptions: [6, 12] }}
slotProps={{ tableViewport: { style: { maxHeight: 280 } } }}
stickyHeader
striped
bordered
/>
);
}
render( );
```
## Density
`size` scales the cell padding — `'xsmall'`, `'small'`, or `'base'` (the
default) — and surfaces as `data-size` for recipes to scope against.
```tsx
function DensityDemo() {
const [size, setSize] = React.useState('base');
const data = [
{ id: '1', flight: 'TK1980', from: 'IST', to: 'LHR', status: 'On time' },
{ id: '2', flight: 'TK1', from: 'IST', to: 'JFK', status: 'Delayed' },
{ id: '3', flight: 'TK162', from: 'ESB', to: 'IST', status: 'Boarding' },
];
const columns = [
{ id: 'flight', header: 'Flight', accessor: 'flight' },
{ id: 'from', header: 'From', accessor: 'from' },
{ id: 'to', header: 'To', accessor: 'to' },
{ id: 'status', header: 'Status', accessor: 'status' },
];
return (
{['xsmall', 'small', 'base'].map((value) => (
setSize(value)}
>
{value}
))}
r.id} size={size} bordered />
);
}
render( );
```
## Empty state
When `data` is empty, Table renders a single full-width cell. The default copy
is `No data`; pass `emptyState` to supply your own node. While `loading` is true
the empty copy is suppressed so it does not flash mid-fetch.
```tsx
function EmptyDemo() {
const columns = [
{ id: 'flight', header: 'Flight', accessor: 'flight' },
{ id: 'from', header: 'From', accessor: 'from' },
{ id: 'to', header: 'To', accessor: 'to' },
];
return (
r.id}
bordered
emptyState={
No flights match your filters.
}
/>
);
}
render( );
```
## Loading
`loading` overlays a Spar `Spinner` (`role="status"`), marks the table
`aria-busy`, and sets `data-loading` for theming. Existing rows stay visible
beneath the overlay so the table does not collapse during a refetch.
```tsx
function LoadingDemo() {
const [loading, setLoading] = React.useState(true);
const data = [
{ id: '1', flight: 'TK1980', from: 'IST', to: 'LHR' },
{ id: '2', flight: 'TK1', from: 'IST', to: 'JFK' },
{ id: '3', flight: 'TK162', from: 'ESB', to: 'IST' },
];
const columns = [
{ id: 'flight', header: 'Flight', accessor: 'flight' },
{ id: 'from', header: 'From', accessor: 'from' },
{ id: 'to', header: 'To', accessor: 'to' },
];
return (
setLoading((v) => !v)}>
{loading ? 'Stop loading' : 'Start loading'}
r.id} loading={loading} bordered />
);
}
render( );
```
## Server (manual) data
In `manual` mode Table processes nothing in-memory. It maps to TanStack's
`manualSorting` / `manualFiltering` / `manualPagination` and emits **one bundled
`onDataRequest`** derived from the current sorting, filters, and pagination —
fetch the page yourself and feed the result back through `data`. `pagination`
must carry `rowCount` so the page count and Next/Last controls can be computed.
```tsx
row.id}
manual
sorting={{ value: sorting, onChange: setSorting }}
filtering={{ value: filters, onChange: setFilters }}
pagination={{
pageSize,
pageIndex,
rowCount: page.total,
onChange: setPagination,
}}
onDataRequest={({ pagination, sorting, filters }) =>
fetchPage({ pagination, sorting, filters })
}
loading={isFetching}
/>
```
## Exporting data
Table ships **no export engine** — it exposes the current filtered + sorted rows
as a plain value projection via `getExportRows(tableRef.current)`. Formatting
and file download (CSV / Excel / PDF) stay on your side, keeping those heavy
dependencies out of the bundle.
```tsx
import { Table, getExportRows } from '@takeoff-ui/react-spar';
const tableRef = useRef(null);
r.id}
tableRef={tableRef}
/>;
// later — e.g. a "Download CSV" button:
const rowsToExport = getExportRows(tableRef.current); // Array>
```
## Accessibility
- Renders a native `` / `` / ` ` / `` /
` `, so screen-reader table navigation, header association, and row/column
counts come for free.
- Sortable headers are real ``s (Enter / Space toggle sorting) and the
`` carries `aria-sort` (`ascending` | `descending` | `none`).
- Row selection composes Spar `Checkbox` (multiple) or `Radio` (single) with
accessible names; the select-all header checkbox is multiple-mode only.
- Pagination is a labelled `navigation` region; the page-size control is a Spar
`Select` with an accessible name, and the nav buttons carry `aria-label`s.
- The loading overlay surfaces the Spar `Spinner` (`role="status"`) and the
table is marked `aria-busy` while `loading`.
| Key | Behavior |
| ----------------------------------- | ----------------------------------------------- |
| Tab | Move between interactive controls in the table. |
| Enter / Space | Toggle sorting on a focused sortable header. |
## API Reference
### Table {#table}
See [TanStack Table docs](https://tanstack.com/table/latest/docs/introduction)
for primitive behavior.
#### Props {#table-props}
| Name | Type | Default | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data | `TData[]` | - | Row data. In `manual` mode this is the current server page. |
| columns | `TableColumnDef[]` | - | Column definitions. See `TableColumnDef`. |
| getRowId | `(row: TData, index: number) => string` | - | Stable row identity (single source of truth). |
| size | `TableSize` | 'base' | Density scale. |
| striped | `boolean` | false | Zebra-stripe rows → `data-striped`. |
| bordered | `boolean` | false | Cell borders → `data-bordered`. |
| stickyHeader | `boolean` | false | Pin the header row during vertical scroll → `data-sticky-header`. |
| manual | `boolean` | false | Server mode. When `true`, Table processes nothing in-memory — it maps to TanStack's `manualSorting`/`manualFiltering`/`manualPagination` and emits a single bundled `onDataRequest` (RFC §3.3). |
| selection | `TableSelectionConfig` | - | Row selection (single/multiple + select-all). Composes Spar Checkbox/Radio. |
| sorting | `TableSortingConfig` | - | Sorting (multi-sort opt-in). |
| filtering | `TableFilteringConfig` | - | Column filtering. Filter UIs render in a Spar `Popover`. |
| expansion | `TableExpansionConfig` | - | Expandable rows with a render-prop body. |
| pagination | `boolean \| TablePaginationConfig` | - | Pagination. `true` enables it with defaults; an object configures it. |
| loading | `boolean` | false | Loading state → `data-loading` + a loading overlay. |
| emptyState | `React.ReactNode` | - | Content rendered when there are no rows. |
| getSubRows | `(row: TData) => TData[]` | - | Sub-row reader for **tree data** (feeds TanStack `getSubRows`). Pair it with `expansion` (sans `render`) so expanding a row reveals its flattened sub-rows. When `getSubRows` is omitted, expansion falls back to the detail-panel mode driven by `expansion.render`. Supplying both keeps tree mode and suppresses the detail panel. |
| tableRef | `Ref \| null>` | - | Escape hatch for the rare imperative need — receives the TanStack table instance (RFC §2.3: controlled props + an optional instance ref, never an imperative `@Method` surface). Also the access point for `getExportRows()`. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root container. |
#### Events {#table-events}
| Name | Type | Default | Description |
| ---------------------------------- | -------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------- |
| onDataRequest | `(request: TableDataRequest) => void` | - | Bundled server data-request callback (`manual` mode). See `TableDataRequest`. |
#### Data attributes {#table-data-attributes}
| Attribute | Applied when | Purpose |
| --------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for the root container. |
| data-slot="table-viewport" | Always | The table-only horizontal/vertical scroll container; pagination remains outside it. |
| data-size | Always | Reflects the resolved `size` (density) so recipes can scope cell padding. |
| data-striped | When `striped` is true. | Enables zebra striping on body rows. |
| data-bordered | When `bordered` is true. | Adds vertical separators between columns. |
| data-sticky-header | When `stickyHeader` is true. | Pins the header row during vertical scroll. |
| data-scrolled | On the table viewport while `scrollTop > 0`. | Adds sticky-header elevation only after vertical scrolling begins. |
| data-loading | When `loading` is true. | Shows the loading overlay; the table is also marked `aria-busy`. |
| data-align | On every header/body cell with an `align` (or column `meta.headerAlign`). | Drives cell `text-align` (`start` \| `center` \| `end`). |
| data-sticky | On cells of a column with `sticky: "left" \| "right"`. | Pins the column; per-edge offset + z-index are applied inline. |
| aria-sort | On sortable header cells. | A11y sort state (`ascending` \| `descending` \| `none`); the sort arrow mirrors it via `data-direction`. |
| data-selected | On a selected body row. | Theme hook for the selected-row background. |
### Type Definitions {#table-type-definitions}
| Name | Definition |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TableSize | `'xsmall' \| 'small' \| 'base'` |
| TableSelectionConfig | `{ mode: 'single' \| 'multiple' }` |
| TableSortingConfig | `{ multi?: boolean }` |
| TableFilteringConfig | `{ value?: T; defaultValue?: T; onChange?: (value: T) => void }` |
| TablePaginationConfig | `{ pageSize?: number; pageIndex?: number; pageSizeOptions?: number[]; rowCount?: number; onChange?: (pagination: PaginationState) => void }` |
| TableSlot | `\| 'root' \| 'tableViewport' \| 'table' \| 'header' \| 'headerRow' \| 'headerCell' \| 'headerContent' \| 'sortTrigger' \| 'sortIcon' \| 'body' \| 'row' \| 'cell' \| 'selectionCell' \| 'expandCell' \| 'expandButton' \| 'expandedRow' \| 'filterButton' \| 'filterPanel' \| 'pagination' \| 'paginationInfo' \| 'paginationNav' \| 'paginationActions' \| 'paginationSize' \| 'paginationGoToPage' \| 'empty' \| 'loading'` |
| TableDataRequest | `{ pagination: PaginationState; sorting: SortingState; filters: ColumnFiltersState }` |
## When to use
Rendering tabular data with any of sorting, filtering, pagination, row selection, expandable/tree rows, sticky columns, loading/empty states, or server-side paging. `getRowId` is mandatory — it is the identity source row selection keys on.
---
# Progress
`Progress` shows how far a task has advanced as a horizontal bar (`'linear'`) or
a ring (`'circular'`). The anatomy is the same for both appearances: the root is
the wrapper and accessibility owner, `Progress.Track` is the rail (the linear
rail bar, or the ring svg drawing the rail circle), `Progress.Indicator` is the
filled portion, and `Progress.Value` composes decorative value or status text —
in flow next to the linear track, centered inside the circular ring.
Set `indeterminate` while the work's extent is unknown — the indicator loops a
sweep animation instead of a fill. For standalone loading states with no
progress semantics, a [`Spinner`](/docs/components/spinner) is still the right
component.
The root carries `role="progressbar"` with the full `aria-valuemin` /
`aria-valuemax` / `aria-valuenow` surface, so assistive technology announces the
value without extra wiring. Compose it inside a
[`Field`](/docs/components/input#using-with-field) with `Field.Label` for a
visible label — the accessible name and the field's disabled state wire up
automatically.
## Usage
```tsx
import { Field, Progress } from '@takeoff-ui/react-spar';
```
```tsx
Upload progress
%40
%66
```
## Playground
```tsx
function PlaygroundDemo() {
const [value, setValue] = React.useState(0);
React.useEffect(() => {
const timer = setInterval(() => {
setValue(prev => (prev >= 100 ? 0 : Math.min(prev + 4, 100)));
}, 400);
return () => clearInterval(timer);
}, []);
return (
Uploading files
%{value}
%{value}
);
}
render( );
```
## Variants
`variant` recolors the filled portion of the track.
```tsx
function VariantsDemo() {
const variants = ['primary', 'info', 'success', 'danger', 'warning'];
return (
{variants.map(variant => (
{variant}
%60
))}
);
}
render( );
```
## Circular
`appearance="circular"` renders `Progress.Track` as the ring svg — it draws the
rail circle and hosts the arc indicator. `Progress.Value` composes value or
status text centered inside the ring — it is decorative (`aria-hidden`), so keep
the accessible name on the root via `aria-label` or a `Field.Label`.
```tsx
function CircularDemo() {
return (
{[25, 50, 75].map(value => (
%{value}
))}
);
}
render( );
```
## Sizes
`size` scales the linear track height and the composed linear `Progress.Value`
typography, plus the circular ring diameter. Circular sizes are `64px`, `96px`,
and `128px` for `small`, `base`, and `large` — the centered `Progress.Value`
typography scales with the ring automatically.
```tsx
function SizesDemo() {
const sizes = ['small', 'base', 'large'];
return (
{sizes.map(size => (
{size}
%60
))}
{sizes.map(size => (
%60
))}
);
}
render( );
```
## Indeterminate
Set `indeterminate` when the work's extent is unknown — the indicator loops a
sweep animation, the root drops `aria-valuenow`, and `data-indeterminate` is
emitted for styling hooks. It takes precedence over `value`.
```tsx
function IndeterminateDemo() {
return (
);
}
render( );
```
## Disabled
`disabled` mutes the fill and sets `aria-disabled`. Inside a disabled `Field`
the state is inherited, so the label and the filled portion gray out together.
```tsx
function DisabledDemo() {
return (
);
}
render( );
```
## Block / Inline
Linear progress can be composed as a block pattern with the percentage below the
bar, or as an inline pattern with the percentage beside the bar. The linear root
stacks `Progress.Track` and `Progress.Value` with the design's sign gap; for the
inline pattern flip the root to a row layout with an inline `style` (the
recipe's layout styles take precedence over utility classes).
```tsx
function LayoutsDemo() {
return (
Block progress
%40
Inline progress
%40
);
}
render( );
```
## Accessibility
- The root exposes `role="progressbar"` with `aria-valuemin`, `aria-valuemax`,
and the clamped `aria-valuenow`. When indeterminate, `aria-valuenow` is
omitted so assistive technology announces a busy progressbar instead of a
bogus percentage.
- Composing inside a `Field` with `Field.Label` wires `aria-labelledby` to the
label automatically. Outside a `Field`, pass `aria-label` or
`aria-labelledby`; without either, the root falls back to a default
`aria-label`.
- Pass `aria-valuetext` when the raw number reads poorly — e.g.
`aria-valuetext="3 of 10 steps"` — and assistive technology announces the
formatted text instead of the percentage.
- The rendered track, indicator, and `Progress.Value` are decorative and hidden
from assistive technology — the value is announced through `aria-valuenow`.
- Motion respects `prefers-reduced-motion`: the determinate fill transition is
dropped entirely, and the indeterminate sweep slows down (it still conveys
"working", the same policy as `Spinner`).
## API Reference
### Progress {#progress}
#### Props {#progress-props}
| Name | Type | Default | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| children | `React.ReactNode` | - | Optional anatomy override. When omitted, the root renders the default anatomy — `Progress.Track` wrapping `Progress.Indicator` — for both appearances. |
| value | `number` | 0 | Current progress value. Clamped to `[min, max]`; non-finite values resolve to `min`. Ignored while `indeterminate` is set. |
| indeterminate | `boolean` | false | Marks the progress as indeterminate — the root drops `aria-valuenow`, emits `data-indeterminate`, and the indicator animates a looping sweep instead of a fill. Takes precedence over `value`. |
| min | `number` | 0 | Minimum value the progress starts from. Non-finite values fall back to the default. |
| max | `number` | 100 | Maximum value the progress can reach. Non-finite values and values at or below `min` fall back to `min + 100` (the latter with a dev-only console warning, since an inverted range is a consumer bug). |
| appearance | `ProgressAppearance` | 'linear' | Shape of the progress indicator — a horizontal bar (`'linear'`) or a ring (`'circular'`). |
| size | `ProgressSize` | 'base' | Visual scale. Linear progress changes track height; circular progress changes ring diameter. |
| variant | `ProgressVariant` | 'primary' | Fill color variant. |
| disabled | `boolean` | false | Mutes the fill color and sets `aria-disabled`. Inherits the surrounding `Field`'s disabled state when composed inside one. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Data attributes {#progress-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-type | Always | Reflects the resolved `appearance` prop (`linear` \| `circular`). |
| data-size | Always | Reflects the resolved `size` prop so recipes can scale the bar or ring. |
| data-variant | Always | Reflects the resolved `variant` prop so theme recipes can recolor the fill. |
| data-disabled | disabled (own prop or inherited from a surrounding `Field`) | Mutes the fill color through the recipe. |
| data-indeterminate | `indeterminate` | Marks the indeterminate state; `aria-valuenow` is dropped alongside it. |
| data-complete | Determinate and the clamped `value` reaches `max` | Styling hook for finished states (e.g. a success fill at 100%). It flips the instant the value reaches `max` — the fill’s 0.3s transition may still be catching up visually, so completion styling leads the fill slightly. |
### Progress.Track {#progress-track}
#### Data attributes {#progress-track-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-slot="rail" | appearance="circular" | The rail `` the ring svg draws; style or override it via `classNames.rail` / `slotProps.rail`. |
| data-type | Always | Reflects the root’s resolved `appearance` so the part’s recipe styles itself without root selectors. |
### Progress.Indicator {#progress-indicator}
#### Data attributes {#progress-indicator-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-type | Always | Reflects the root’s resolved `appearance` so the part’s recipe styles itself without root selectors. |
| data-indeterminate | `indeterminate` | Drives the looping sweep animation instead of a written fill. |
### Progress.Value {#progress-value}
#### Data attributes {#progress-value-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-type | Always | Reflects the root’s resolved `appearance` so the part’s recipe styles itself without root selectors. |
### Type Definitions {#progress-type-definitions}
| Name | Definition |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| ProgressAppearance | `'linear' \| 'circular'` |
| ProgressSize | `'small' \| 'base' \| 'large'` |
| ProgressVariant | `'primary' \| 'info' \| 'success' \| 'danger' \| 'warning'` |
| ProgressTrackSlot | `'root' \| 'rail'` |
## When to use
Work with a known percentage — file uploads, imports, quota and storage meters, multi-item batch jobs — or a bar-shaped indeterminate treatment for a long-running task. Not this — use `takeoff-spinner` for short waits where a bar would be visual noise, and `takeoff-skeleton` when you are holding the shape of content that is still loading.
---
# Spinner
A compact indeterminate loading indicator with built-in status semantics.
## Usage
```tsx
import { Spinner } from '@takeoff-ui/react-spar';
```
```tsx
```
## Playground
```tsx
function PlaygroundDemo() {
return (
);
}
render( );
```
## Variants
```tsx
function VariantsDemo() {
return (
);
}
render( );
```
## Appearances
```tsx
function AppearancesDemo() {
return (
);
}
render( );
```
## Sizes
```tsx
function SizesDemo() {
return (
);
}
render( );
```
## Label
`Spinner` does not provide label layout. When visible loading text is needed,
compose it with `Label` and connect the text with `aria-labelledby`.
```tsx
function LabelDemo() {
return (
Loading flights
Saving
Preparing
);
}
render( );
```
## Accessibility
`Spinner` renders a polite status by default. Use `aria-label` or
`aria-labelledby` for a domain-specific name; use native `aria-hidden` only when
the spinner is purely decorative next to another loading message. When
`aria-hidden` is true, `Spinner` does not apply the status role or default
accessible name.
```tsx
function AccessibilityDemo() {
return (
);
}
render( );
```
## API Reference
### Spinner {#spinner}
#### Props {#spinner-props}
| Name | Type | Default | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------- |
| size | `SpinnerSize` | 'base' | Size scale. |
| appearance | `SpinnerAppearance` | 'rounded' | Visual spinner style. |
| variant | `SpinnerVariant` | 'neutral' | Color variant. |
| classNames | `Partial>` | - | Per-slot extra classes. |
| slotProps | `Partial>>` | - | Per-slot HTML-attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot. |
| aria-label | `string` | 'Loading' | Accessible name for the loading status. Override it for domain-specific loading text. |
| aria-labelledby | `string` | - | ID reference for visible loading text. When provided, the default `aria-label` is not applied. |
| aria-hidden | `boolean` | false | Hides a decorative spinner from assistive technology. When true, status role and default accessible name are not applied. |
#### Data attributes {#spinner-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-variant | Always | Reflects the resolved `variant` prop for theme recipe scoping. |
| data-size | Always | Reflects the resolved `size` prop for theme recipe scoping. |
| data-type | Always | Reflects the resolved `appearance` prop for theme recipe scoping. |
### Type Definitions {#spinner-type-definitions}
| Name | Definition |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| SpinnerSize | `'small' \| 'base' \| 'large' \| 'xlarge'` |
| SpinnerAppearance | `'rounded' \| 'dots' \| 'lines' \| 'pulse' \| 'threeDots' \| 'loader' \| 'logo'` |
| SpinnerVariant | `'primary' \| 'secondary' \| 'neutral' \| 'info' \| 'success' \| 'danger' \| 'warning'` |
| SpinnerSlot | `'root' \| 'indicator'` |
## When to use
Show ongoing, indeterminate activity (fetching, saving, processing) where progress is unknown. Not this — use a progress bar component for determinate percentage progress.
---
# Skeleton
`Skeleton` renders a visual placeholder for content that is still loading. Use
it when the final layout is known enough to reserve space and avoid a jumpy
transition when data arrives.
The component is decorative by default and renders `aria-hidden="true"`. Pair it
with surrounding loading text, a region state, or another status component when
the loading state needs to be announced.
## Usage
```tsx
import { Skeleton } from '@takeoff-ui/react-spar';
```
```tsx
```
## Playground
```tsx
function PlaygroundDemo() {
return (
);
}
render( );
```
## Shapes
`shape="rectangle"` renders a rounded bar. `shape="circle"` turns the root into
a full-radius disc whose diameter follows `height`.
```tsx
function ShapesDemo() {
return (
);
}
render( );
```
## Sizing
`width` and `height` accept numbers in pixels or any CSS length string. Omitting
`width` lets the placeholder fill its container.
```tsx
function SizingDemo() {
return (
);
}
render( );
```
## Animation
Use `animation="none"` for static placeholders, especially inside surfaces where
motion would distract from other loading or status indicators.
```tsx
function AnimationDemo() {
return (
);
}
render( );
```
## Accessibility
- `Skeleton` is hidden from assistive technology by default because it does not
carry meaningful content on its own.
- Announce loading through nearby copy, a labeled region with `aria-busy`, or a
[`Spinner`](/docs/components/spinner) when users need active status feedback.
- Avoid replacing labels, headings, or controls with focusable placeholders.
Render the real interactive element only when it is ready.
## API Reference
### Skeleton {#skeleton}
#### Props {#skeleton-props}
| Name | Type | Default | Description |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| shape | `SkeletonShape` | 'rectangle' | Placeholder silhouette. `rectangle` renders the rounded bar, `circle` a full-radius disc whose diameter tracks `height`. |
| animation | `SkeletonAnimation` | 'shimmer' | Loading animation. `shimmer` sweeps the highlight strip across the bar; `none` renders a static placeholder. |
| width | `string \| number` | - | Fixed width, number in px or any CSS length. Published to the recipe as `--tk-skeleton-width`; defaults to filling the container. |
| height | `string \| number` | - | Fixed height, number in px or any CSS length. Published to the recipe as `--tk-skeleton-height`. |
| classNames | `Partial>` | - | Per-slot extra classes. |
| slotProps | `Partial>>` | - | Per-slot HTML-attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot. |
| aria-hidden | `boolean \| "true" \| "false"` | true | Keeps the placeholder out of the accessibility tree. Leave it hidden and expose loading context through nearby content. |
#### Data attributes {#skeleton-data-attributes}
| Attribute | Applied when | Purpose |
| -------------------------------------------------- | ------------------------ | ---------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-type | Always | Reflects the resolved `shape` prop for theme recipe scoping. |
| data-animation | Always | Reflects the resolved `animation` prop for theme recipe scoping. |
| data-slot="shimmer" | Always on the inner span | Stable selector for the decorative shimmer strip. |
### Type Definitions {#skeleton-type-definitions}
| Name | Definition |
| -------------------------------------------------------------------------------------- | ---------------------------------------- |
| SkeletonShape | `'rectangle' \| 'circle'` |
| SkeletonAnimation | `'shimmer' \| 'none'` |
| SkeletonSlot | `'root' \| 'shimmer'` |
## When to use
First-load placeholders for content whose layout you already know — cards, list rows, avatars, text lines, table cells. Reach for it when leaving the space blank would make the page jump once data arrives. Not this — use `takeoff-spinner` for short, indeterminate waits with no known shape (a saving button, an inline refresh), and `takeoff-progress` when completion is measurable.
---
# Slider
`Slider` picks a number — or a `[min, max]` range — from a continuous scale. It
is a standalone component (no upstream Spar primitive), so the wrapper owns the
value math, the pointer dragging, and the full keyboard and ARIA surface. Each
thumb is its own `role="slider"` element, which is what assistive technology
reads and what the arrow keys drive.
## Usage
```tsx
import { Slider } from '@takeoff-ui/react-spar';
```
```tsx
```
Compose it inside a `Field` to wire the visible label, the description, and the
shared `disabled` / `readOnly` / `invalid` / `required` state automatically.
## Playground
```tsx
function PlaygroundDemo() {
const [value, setValue] = React.useState(22);
return (
Cabin temperature
v + '°C'}
>
{/* The root stacks its children; this row puts the readout
beside the rail, with the scale bounds under it. */}
);
}
render( );
```
## Range
Set `range` to render two thumbs. The value becomes a `[min, max]` tuple, and
`onValueChange` reports it in the same shape. Dragging one handle past the other
swaps them, so the committed tuple always stays ascending.
```tsx
function RangeDemo() {
const [price, setPrice] = React.useState([250, 900]);
return (
Price range
'$' + v}
/>
);
}
render( );
```
## Multiple handles
The number of thumbs is decided by the value array, not by a separate prop: pass
three entries and three handles render, each bounded by its neighbours.
`onValueChange` reports the whole array, ordered ascending.
The outer handles keep the `data-thumb="min"` / `"max"` styling hooks; a handle
in between is neither, so it carries no `data-thumb` at all. Accessible names
follow the same logic — a two-handle range reads as _Minimum_ / _Maximum_, while
three or more fall back to _Value 1_, _Value 2_, … which consumers can override
per thumb with `aria-label`.
```tsx
function MultiThumbDemo() {
const [stops, setStops] = React.useState([20, 50, 80]);
return (
Gradient stops
v + '%'}
/>
Committed: {stops.join(' · ')}
);
}
render( );
```
## Minimum distance
Set `minDistance` to keep a gap between adjacent range handles. With a positive
gap the handles can no longer cross — dragging one into another stops it against
its neighbour instead of swapping. Seed the initial values at least this far
apart; they are not reshaped.
```tsx
function MinDistanceDemo() {
const [range, setRange] = React.useState([30, 70]);
return (
Allowed window
{/* minDistance keeps the handles at least 20 apart — they clamp against
each other instead of crossing. */}
v + '%'}
/>
{range[0]}% – {range[1]}% (gap {range[1] - range[0]})
);
}
render( );
```
## Sync with an input
The slider is controlled like any input — bind `value` / `onValueChange` to
state and pair it with a number field for exact entry. Typing moves the thumb;
dragging updates the field.
```tsx
function InputDemo() {
const [value, setValue] = React.useState(40);
const clamp = (n) => Math.min(100, Math.max(0, Number.isFinite(n) ? n : 0));
return (
);
}
render( );
```
## Change events
`onValueChange` fires on every committed change while interacting — each drag
frame, keystroke, or track press. `onValueChangeEnd` fires once when the
interaction settles (pointer release, or a single keystroke), with the final
value — use it when only the settled value matters, e.g. to persist or refetch.
```tsx
function EventsDemo() {
const [live, setLive] = React.useState(40);
const [committed, setCommitted] = React.useState(40);
return (
Brightness
{/* onValueChange streams every frame; onValueChangeEnd fires once the
drag (or keypress) settles — drag the handle to see them diverge. */}
Live: {live} · Committed on release: {committed}
);
}
render( );
```
## Value tooltip
The value bubble follows the handle on drag or keyboard focus by default
(`tooltip="auto"`). Set `tooltip="always"` to pin it open, or `tooltip="never"`
to hide it entirely — useful when a `Slider.Value` already surfaces the number.
A disabled slider hides the bubble either way.
The bubble is a CSS node parented to the handle rather than a portaled overlay,
so an ancestor with `overflow: hidden` or `overflow: auto` clips it — for
example a slider near the top of a scrollable dialog body or inside a table
cell. In an overflow container, prefer `Slider.Value` (which stays in flow) or
leave `tooltip="auto"` so the bubble only appears transiently on drag/focus.
```tsx
function TooltipDemo() {
return (
{/* Pinned open — the bubble stays visible without interaction. */}
Always visible
v + '%'} />
{/* Hidden even on drag/focus — Slider.Value carries the number instead.
Field.Label and Slider.Value sit inside
so the readout reads
the live value from context and can share a row with the label. */}
v + '%'}>
Muted bubble
);
}
render( );
```
## Ticks
The default anatomy is the track alone. To mark the step grid below it, compose
`Slider.Ticks` after `Slider.Track` — an indicator is anatomy, so it is added by
composition rather than switched on by a prop.
```tsx
function TypesDemo() {
return (
);
}
render( );
```
## Track fill
`track` sets how the rail fills. `normal` (default) fills from the start to the
thumb (or between a range's handles); `inverted` fills the complement — from the
thumb to the end for a single slider, and outside the two handles for a range;
`none` drops the fill entirely, leaving just the rail and the thumbs.
```tsx
function TrackDemo() {
return (
{/* inverted (single) — the complement fills: from the thumb to the end. */}
Inverted fill
v + '%'} />
{/* inverted (range) — the fill sits outside the two handles. */}
Inverted range
v + '%'} />
{/* none — the fill is dropped; the rail and thumb stay. */}
No fill
);
}
render( );
```
## Orientation
Set `orientation="vertical"` to run the rail bottom-to-top: the bottom edge is
`min`, dragging upward increases the value, and the keyboard behaves the same
(Up/Right increase, Down/Left decrease). Every thumb reports the axis through
`aria-orientation`.
A vertical rail has **no intrinsic length** — a horizontal one takes its width
from the parent, but nothing gives a vertical one a height. It fills its
container, so give the slider (or a wrapping element) a height:
```css
.tk-slider[data-orientation='vertical'] {
height: 240px;
}
```
```tsx
function OrientationDemo() {
const [level, setLevel] = React.useState(60);
return (
Altitude
v + '%'}
/>
With ticks
);
}
render( );
```
## Sizes
```tsx
function SizesDemo() {
return (
);
}
render( );
```
## Variants
```tsx
function VariantsDemo() {
return (
);
}
render( );
```
## States
```tsx
function StatesDemo() {
const [level, setLevel] = React.useState(75);
// The invalid treatment is driven by the value: at or above 50 it fails,
// below 50 the danger fill and the error message both clear.
const invalid = level >= 50;
return (
Disabled
Read-only
Invalid
{invalid && (
Pick a value under 50.
)}
Individual thumb disabled
{/* Only the lower handle is pinned; the upper one stays interactive. */}
);
}
render( );
```
## Custom styling
Every part accepts `className`, `classNames`, `style`, and `slotProps`, so the
whole slider is yours to re-skin without leaving the composition — the rail
(`Slider.Track`), the fill (`Slider.Range`), and the handle (`Slider.Thumb`,
whose slots are the `root` handle, the `tooltip` bubble, and the `arrow`
pointer), plus the bubble's content through a `Slider.Thumb` child. The bubble
and its arrow share the `--tk-slider-tooltip-bg` custom property, so one
override recolours both, while the arrow keeps its own `--tk-slider-arrow-width`
/ `-height`. The fill gradient is pinned to the full track width with
`background-size` so it maps to the absolute scale rather than stretching across
the current fill — a low value stays blue instead of squeezing the whole
spectrum into a sliver. The tooltip is revealed on drag or keyboard focus, so
grab the handle to see it.
```tsx
function CustomStyledDemo() {
const [warmth, setWarmth] = React.useState(60);
// Zone colour off the absolute value, reused by the fill dot in the bubble.
const zone = (v) => (v < 40 ? '#38bdf8' : v < 70 ? '#fbbf24' : '#ef4444');
return (
Warmth
v + '°'}
>
{/* Every part takes style / slotProps, so the whole slider is yours
to re-skin: a taller rail, a gradient fill, a ringed handle, and
a custom tooltip (shown on drag or focus). */}
{/* Pin the gradient to the full track width (448px = the max-w-md
container) so it maps to the absolute scale instead of stretching
across the current fill — a low value then stays blue. */}
{({ value, formatted }) => (
{formatted}
)}
);
}
render( );
```
## Keyboard
| Key | Action |
| ------------------------- | -------------------------------------------- |
| ← ↓ | Decrease the focused thumb by one `step` |
| → ↑ | Increase the focused thumb by one `step` |
| Page Down | Decrease by ten steps |
| Page Up | Increase by ten steps |
| Home | Jump to the lowest value the thumb may take |
| End | Jump to the highest value the thumb may take |
In a range slider each thumb is bounded by its neighbour, so the keyboard can
never push one handle past the other.
## API Reference
### Slider {#slider}
#### Props {#slider-props}
| Name | Type | Default | Description |
| --------------------------------- | ----------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| range | `boolean` | false | Renders a multi-handle slider: the value becomes an array and one thumb renders per entry (two by default). Dragging one handle past another swaps them, so the committed array stays ascending. |
| value | `number \| number[]` | - | Controlled value — a `number` by default, an array when `range` is set (one thumb per entry; fewer than two entries fall back to `[min, max]`). Each entry is clamped into `[min, max]`, snapped to `step`, and the array is ordered ascending. Takes precedence over `defaultValue`, which is ignored when both are passed. |
| defaultValue | `number \| number[]` | `min` | Initial value for uncontrolled usage — a `number` by default, an array when `range` is set (one thumb per entry). Defaults to `min` for a single slider and `[min, max]` for a range. Controlled/uncontrolled mode is latched on the first render. |
| min | `number` | 0 | Lowest selectable value. Non-finite values fall back to the default. |
| max | `number` | 100 | Highest selectable value. Non-finite values and values at or below `min` fall back to `min + 100` (with a dev-only console warning, since an inverted range is a consumer bug). |
| step | `number` | 1 | Granularity the value snaps to, counted from `min`. Non-finite and non-positive values fall back to the default. |
| minDistance | `number` | 0 | Minimum gap kept between adjacent thumbs of a range, in value units. With a positive gap the handles can no longer cross — dragging one into another stops it against its neighbour instead of swapping. Ignored by a single slider; initial values are not reshaped, so seed them at least this far apart. |
| disabled | `boolean` | false | Blocks interaction and mutes the fill. Inherits the surrounding `Field`'s disabled state when composed inside one. |
| readOnly | `boolean` | false | Renders the value but blocks every value-changing interaction. Inherits the surrounding `Field`'s read-only state when composed inside one. |
| required | `boolean` | false | Marks the slider as required for form validation. Inherits the surrounding `Field`'s required state when composed inside one. |
| invalid | `boolean` | false | Applies the invalid treatment and sets `aria-invalid` on every thumb. Inherits the surrounding `Field`'s invalid state when composed inside one. |
| orientation | `SliderOrientation` | 'horizontal' | Axis the rail runs along. A vertical rail runs bottom-to-top and fills its container's height (as a horizontal rail fills its width), so give the parent a height. |
| size | `SliderSize` | 'base' | Visual scale — changes track thickness and thumb diameter. |
| variant | `SliderVariant` | 'primary' | Fill color variant. |
| track | `SliderTrackMode` | 'normal' | How the rail fill renders. `normal` fills from the start to the thumb (or between a range's thumbs); `inverted` fills the _complement_ instead — from the thumb to the end for a single slider, and outside the two handles for a range; `none` drops the fill entirely, leaving just the rail and the thumbs. |
| tooltip | `SliderTooltip` | 'auto' | When the drag value bubble is shown. `auto` reveals it while the handle is dragged or keyboard-focused; `always` pins it open; `never` hides it entirely — handy when a `Slider.Value` already surfaces the number. A disabled slider hides the bubble regardless. |
| formatValue | `(value: number) => string` | - | Formats a value for every readout — the drag tooltip, `Slider.Value`, and `aria-valuetext`. Returns a string, so it formats the value rather than overriding the rendered node; use `Slider.Thumb` / `Slider.Value` children for richer content. When omitted, the raw number is shown and `aria-valuetext` is dropped. |
| name | `string` | - | Name submitted with the form. A single slider submits one entry under this exact name; a two-handle range submits `-min` / `-max`, and a range with more handles submits each value under a 1-based `-1`, `-2`, … suffix so no handle is dropped. |
| form | `string` | - | `id` of the form the hidden inputs belong to. |
| children | `React.ReactNode` | - | Optional anatomy override. When omitted, `Slider` renders `Slider.Track` wrapping `Slider.Range` and one thumb per value. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot. |
#### Events {#slider-events}
| Name | Type | Default | Description |
| ------------------------------------- | ------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onValueChange | `(value: number \| number[]) => void` | - | Fired on every committed value change while interacting — each drag frame, keystroke, or track press. Receives a `number` by default and the full ascending array when `range` is set. |
| onValueChangeEnd | `(value: number \| number[]) => void` | - | Fired once at the end of an interaction with the final value: on pointer release after a drag or track press, and once per committed keystroke. Use it when only the settled value matters, while `onValueChange` streams the live value. |
#### Data attributes {#slider-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved `size` prop for theme recipe scoping (rail thickness and thumb diameter). |
| data-variant | Always | Reflects the resolved `variant` prop that drives the fill color. |
| data-orientation | Always | Reflects the resolved `orientation` prop. A vertical rail runs bottom-to-top and takes its height from its container, as a horizontal rail takes its width. |
| data-tooltip | Always | Reflects the resolved `tooltip` prop (`auto` \| `always` \| `never`) that controls when the value bubble shows. |
| data-track | Always | Reflects the resolved `track` prop (`normal` \| `inverted` \| `none`) that sets the rail fill mode; `inverted` fills the complement of the selection, `none` drops the fill but keeps the rail. |
| data-range | When `range` is set | Marks a multi-thumb slider (two or more handles) so the recipe can style the fill as a band between the outermost handles. |
| data-disabled | When disabled (own prop or inherited from a surrounding `Field`) | Mutes the fill and blocks every interaction. |
| data-readonly | When read-only (own prop or inherited from a surrounding `Field`) | Renders the value while blocking value-changing interaction. |
| data-invalid | When invalid (own prop or inherited from a surrounding `Field`) | Applies the invalid treatment; each thumb also exposes `aria-invalid`. |
| data-required | When required (own prop or inherited from a surrounding `Field`) | Marks the control as required; each thumb also exposes `aria-required`. |
| data-thumb | On each thumb of a range slider | `min` on the first handle and `max` on the last. A handle between them is neither end, so it carries no `data-thumb` rather than a misleading one. |
| data-dragging | On the thumb the pointer currently controls | Reveals the value tooltip and suppresses the position transition so the handle tracks the pointer exactly. |
| data-focus | On the focused thumb | Draws the focus ring and reveals the value tooltip for keyboard users. |
| role="slider" | On every thumb | The thumb is the accessibility owner: it carries `aria-valuenow` / `aria-valuemin` / `aria-valuemax` and the keyboard surface. |
| role="group" | When `range` is set | Ties the two `role="slider"` thumbs together under the surrounding `Field` label. |
### Slider.Track {#slider-track}
#### Data attributes {#slider-track-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-orientation | Inherited from the root (descendant selector) | A vertical rail flips its long axis; the track reads the root’s `data-orientation` rather than carrying its own. |
### Slider.Range {#slider-range}
#### Data attributes {#slider-range-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-track | Inherited from the root (descendant selector) | The `inverted` / `none` fill mode is read from the root’s `data-track`; the band recolours or hides itself accordingly. Its offset and length are written inline (a continuous value is not a `data-*` hook). |
### Slider.Thumb {#slider-thumb}
#### Props {#slider-thumb-props}
| Name | Type | Default | Description |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| index | `number` | 0 | Which value this thumb controls. The default anatomy renders index `0` (and `1` for a range); pass it explicitly only when composing the thumbs by hand. |
| disabled | `boolean` | false | Disables just this handle — it cannot be moved and is skipped as a drag target, while the other thumbs stay interactive. A neighbour dragged into a disabled handle stops against it. The slider’s own `disabled` still disables every thumb. |
| children | `React.ReactNode \| ((state: SliderThumbRenderProps) => React.ReactNode)` | - | Content of the value bubble. When omitted, the formatted value renders. A plain node replaces it with static content; a function receives this thumb’s `value` / `formatted` / `index` / `isDragging` / `isFocused`. Either form swaps only what the bubble shows — the handle and bubble chrome stay the thumb’s. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Data attributes {#slider-thumb-data-attributes}
| Attribute | Applied when | Purpose |
| -------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-slot="tooltip" | Always | The value bubble parented to the handle; style or override it via `classNames.tooltip` / `slotProps.tooltip`. |
| data-slot="arrow" | Always | The bubble’s pointer, a real element (not a pseudo-element) so `classNames.arrow` / `slotProps.arrow` can resize or recolour it. |
| data-thumb | On the first / last handle of a range | `min` on the first handle and `max` on the last; a middle handle carries none. |
| data-dragging | While the pointer controls this handle | Reveals the value bubble and suppresses the position transition so the handle tracks the pointer exactly. |
| data-focus | While this handle holds keyboard focus | Draws the focus ring and reveals the value bubble for keyboard users. |
| data-disabled | When this handle is disabled (own prop or the whole slider) | Mutes just this handle and hides its value bubble. |
| role="slider" | Always | The handle is the accessibility owner: it carries `aria-valuenow` / `aria-valuemin` / `aria-valuemax` and the keyboard surface. |
### Slider.Ticks {#slider-ticks}
#### Data attributes {#slider-ticks-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-slot="tick" | On each step mark | One mark of the grid; style or override it via `classNames.tick` / `slotProps.tick`. Positions are written inline. Rendered `aria-hidden` (decorative). |
### Slider.Value {#slider-value}
#### Data attributes {#slider-value-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Type Definitions {#slider-type-definitions}
| Name | Definition |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| SliderOrientation | `'horizontal' \| 'vertical'` |
| SliderSize | `'small' \| 'base' \| 'large'` |
| SliderVariant | `'primary' \| 'info' \| 'success' \| 'danger' \| 'warning'` |
| SliderTrackMode | `'normal' \| 'inverted' \| 'none'` |
| SliderTooltip | `'auto' \| 'always' \| 'never'` |
| SliderThumbRenderProps | `{ value: number; formatted: string; index: number; isDragging: boolean; isFocused: boolean }` |
| SliderThumbSlot | `'root' \| 'tooltip' \| 'arrow'` |
| SliderTicksSlot | `'root' \| 'tick'` |
| SliderValueRenderProps | `{ values: number[]; formatted: string[]; range: boolean }` |
## When to use
Any bounded numeric selection where dragging beats typing — volume, brightness, temperature, zoom, a price/date range filter, gradient stops. Wrap it in `Field` to attach a label, description, or error, and to cascade `disabled` / `readOnly` / `invalid` / `required`. Not this — use `takeoff-input` (`type="number"`) for exact entry, `takeoff-progress` for a non-interactive completion bar.
## Accessibility
- Each `Slider.Thumb` is the a11y owner: `role="slider"` carrying
`aria-valuenow` / `aria-valuemin` / `aria-valuemax`, plus the keyboard
surface. A `range` root adds `role="group"` to tie the thumbs together under
the `Field` label.
- Compose inside `Field` so `Field.Label` / `Field.Description` /
`Field.ErrorMessage` wire via shared IDs; without a visible label, pass
`aria-label` on the thumb. `formatValue` also feeds `aria-valuetext`.
- Disabled slider leaves the tab order; read-only stays focusable but does not
change value. In a range each thumb is bounded by its neighbour, so the
keyboard can never push one past the other.
| Key | Action |
| ----------- | -------------------------------------------- |
| `←` `↓` | Decrease the focused thumb by one `step` |
| `→` `↑` | Increase the focused thumb by one `step` |
| `Page Down` | Decrease by ten steps |
| `Page Up` | Increase by ten steps |
| `Home` | Jump to the lowest value the thumb may take |
| `End` | Jump to the highest value the thumb may take |
---
# Stepper
`Stepper` guides users through a multi-step flow. Each step renders an indicator
(status dot, status glyph, or custom content), an optional connecting rail, and
a title with an optional description. The active step is index-based and can be
controlled or uncontrolled.
Steps are real `` elements: keyboard users activate them with `Enter` or
`Space`, arrow keys (plus `Home`/`End`) move focus along the list, and the
active step is announced through `aria-current="step"`.
## Usage
```tsx
import { Stepper } from '@takeoff-ui/react-spar';
```
```tsx
```
## Playground
```tsx
function PlaygroundDemo() {
const [active, setActive] = React.useState(1);
return (
Flight
Choose your route
Passengers
Traveler details
Payment
Card or miles
setActive(active - 1)}>
Back
setActive(active + 1)}>
Next
);
}
render( );
```
## Step status
Steps before the active index show the completed treatment. Mark a step with
`error` or `disabled`; both apply as modifiers without replacing the derived
progress status. Disabled steps are natively disabled buttons — they are
unfocusable and unselectable.
```tsx
function StatusDemo() {
return (
i}>
Custom
Error
Disabled
Active
Inactive
);
}
render( );
```
## Numbered indicators
Pass a number through `Stepper.Item`'s `indicator` prop when the indicator
should show step numbers instead of the default status glyphs. `indicator` also
accepts a render function receiving the step's `status` and `index` — returning
`undefined` falls back to the built-in glyphs, so completed steps regain the
check.
```tsx
function NumberedIndicatorsDemo() {
const steps = ['Search', 'Passengers', 'Payment'];
return (
{steps.map((step, index) => (
status === 'completed' ? undefined : {index + 1}
}
>
{step}
))}
);
}
render( );
```
## Linear progression
With `linear`, users can revisit any previous step but only advance to the
immediate next step — and only while the current step is neither errored nor
disabled.
```tsx
function LinearDemo() {
const [active, setActive] = React.useState(0);
return (
Account
Only the next step is clickable
Verification
Done
);
}
render( );
```
## Clickability
Use `isClickable={false}` when a step should stay in the visual flow but not
change the active step on press.
```tsx
function ClickabilityDemo() {
const [active, setActive] = React.useState(0);
return (
Available
Locked
Visible but skipped
Available
);
}
render( );
```
## Reverse
Use `reverse` to flip indicators and content along the cross axis without
changing the step order.
```tsx
function ReverseDemo() {
return (
Origin
Start city
Seats
Selected cabin
Boarding
Final details
);
}
render( );
```
## Vertical
```tsx
function VerticalDemo() {
return (
Order placed
We received your order
Processing
Tickets are being issued
Ready
Check your inbox
);
}
render( );
```
## Compact
`mode="compact"` drops the rails: each step carries a progress border that
recolors with its status.
```tsx
function CompactDemo() {
const [active, setActive] = React.useState(1);
return (
Cabin
Seats
Extras
);
}
render( );
```
## Sizes
```tsx
function SizesDemo() {
return (
{['xsmall', 'small', 'base', 'large'].map(size => (
{size}
Active
Next
))}
);
}
render( );
```
## Accessibility
- The root is an ordered list; each step is a list item wrapping a real
`` trigger, so steps are focusable and keyboard-activatable by
default.
- The active step's trigger carries `aria-current="step"`.
- Steps that cannot change the active step (non-clickable or blocked by
`linear`) expose `aria-disabled` and stay silent on press; `disabled` steps
are natively disabled and removed from the tab order.
- Indicators and rails are decorative and hidden from assistive technology; a
step's accessible name comes from its title text, extended with a visually
hidden status suffix on completed/errored steps — localize it through the
root's `completedLabel`/`errorLabel` props.
- `Stepper.Description` is linked to the trigger through `aria-describedby`
instead of inflating the accessible name.
- Arrow keys along the stepper's orientation move focus between step triggers;
`Home` and `End` jump to the first and last focusable step.
## API Reference
### Stepper {#stepper}
#### Props {#stepper-props}
| Name | Type | Default | Description |
| ----------------------------------- | ----------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| children | `React.ReactNode` | - | `Stepper.Item` elements. The root derives each step’s index from its position in `children`. |
| active | `number` | - | Currently active step index (controlled). Not clamped: an out-of-range index renders every step completed (or inactive) with no active step. |
| defaultActive | `number` | 0 | Initially active step index (uncontrolled). |
| orientation | `StepperOrientation` | 'horizontal' | Layout axis of the step list. |
| mode | `StepperMode` | 'default' | Display mode — indicators with connecting rails (`'default'`) or a progress border per step without rails (`'compact'`). |
| linear | `boolean` | false | Restricts navigation to a linear progression: any previous step, or the next step when the current one is neither errored nor disabled. |
| size | `StepperSize` | 'base' | Density scale for indicators and typography. |
| reverse | `boolean` | false | Flips indicators and content along the cross axis. |
| completedLabel | `string` | 'completed' | Accessible status suffix appended to a completed step's name — the check glyph alone is invisible to assistive technology. Localize per stepper; an empty string drops the suffix. |
| errorLabel | `string` | 'error' | Accessible status suffix appended to an errored step's name — the error glyph alone is invisible to assistive technology. Localize per stepper; an empty string drops the suffix. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Events {#stepper-events}
| Name | Type | Default | Description |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onActiveChange | `(index: number) => void` | - | Fires with the new step index when the active step changes. |
| onStepClick | `(detail: StepperStepClickDetail) => void` | - | Fires on selectable step presses, and on the active step when pressed again, with the step's index and progress status. Disabled, non-clickable, and linear-blocked steps emit nothing. |
#### Data attributes {#stepper-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-orientation | Always | Reflects the resolved `orientation` prop (`horizontal` \| `vertical`). Emitted by the wrapper. |
| data-mode | Always | Reflects the resolved `mode` prop (`default` \| `compact`). |
| data-size | Always | Reflects the resolved `size` prop so theme recipes can scope size variants. |
| data-linear | When `linear` is true. | Marks linear progression; selection gating itself is wrapper-owned. |
| data-reverse | When `reverse` is true. | Styling hook for the flipped indicator/content layout. |
### Stepper.Item {#stepper-item}
#### Props {#stepper-item-props}
| Name | Type | Default | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| children | `React.ReactNode` | - | Step content — typically `Stepper.Title` and `Stepper.Description`. Renders inside the step’s `` trigger and provides its accessible name. |
| error | `boolean` | - | Marks the step as errored without changing its progress status. |
| disabled | `boolean` | - | Disables the step. The trigger renders as a natively disabled button: unfocusable, unselectable, and silent. |
| isClickable | `boolean` | true | Whether the step can be activated by pressing it. Non-clickable steps stay visible in the flow but are removed from the tab order. |
| indicator | `React.ReactNode \| ((state: StepperIndicatorState) => React.ReactNode)` | - | Custom indicator content. Replaces the built-in status glyph (check, close, or dot) for every status except `disabled`. Pass a function to render by status — returning `undefined` or `null` falls back to the built-in glyphs, so numbered steps can surface the check once completed. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Data attributes {#stepper-item-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-state | Always | Resolved progress status: `inactive` \| `active` \| `completed`. |
| data-error | When `error` is true. | Error treatment modifier; can coexist with any progress status. |
| data-disabled | When `disabled` is true. | Disabled treatment modifier; the trigger is natively disabled. |
| data-clickable | When pressing the step may change the active step — never on the active step itself. | Cursor/hover affordance hook. Respects `disabled`, `isClickable`, and linear gating. |
### Stepper.Title {#stepper-title}
#### Data attributes {#stepper-title-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Stepper.Description {#stepper-description}
#### Data attributes {#stepper-description-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Type Definitions {#stepper-type-definitions}
| Name | Definition |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| StepperOrientation | `'horizontal' \| 'vertical'` |
| StepperMode | `'default' \| 'compact'` |
| StepperSize | `'large' \| 'base' \| 'small' \| 'xsmall'` |
| StepperStepClickDetail | `{ index: number; status: StepperStepStatus }` |
| StepperIndicatorState | `{ status: StepperStepStatus; index: number }` |
| StepperItemSlot | `'root' \| 'trigger' \| 'rail' \| 'indicator' \| 'content'` |
## When to use
Multi-step flows where the sequence itself is meaningful and the user should see where they are and what remains — checkout, booking, onboarding, multi-page forms. Not this — use `takeoff-tabs` when sections can be visited in any order, and `takeoff-progress` when you only need to show completion without naming the steps.
---
# Toast
`Toast` displays transient feedback without interrupting the current workflow.
The queue, timers, pause/resume behavior, hotkey focus, and live-region
semantics come from Spar's headless toast controller; React Spar renders each
item with Takeoff Alert anatomy by default.
Use variants to show the visual tone of the toast.
## Usage
```tsx
import { Toaster, createToaster } from '@takeoff-ui/react-spar';
```
```tsx
const toaster = createToaster({ placement: 'bottom-end' });
function App() {
return (
<>
toaster.success({
title: 'Booking saved',
description: 'Passenger details were updated successfully.',
})
}
>
Save
>
);
}
```
## Playground
```tsx
const toaster = createToaster({duration: 6000, placement: 'top-end' });
function PlaygroundDemo() {
const [count, setCount] = React.useState(0);
const notify = () => {
const nextCount = count + 1;
toaster.success({
title: 'Booking saved #' + nextCount,
description: 'Passenger details update #' + nextCount + ' completed successfully.',
action: {
label: 'Undo',
altText: 'Undo booking update',
},
});
setCount(nextCount);
};
return (
Save booking
);
}
render( );
```
## Type/Variants
Toast uses `type` for semantic intent. React Spar maps that intent to the Alert
variant internally, so consumers do not pass a separate `variant` prop.
```tsx
const toaster = createToaster({ placement: 'top-end' });
function TypeVariantDemo() {
const notify = (type, title) => {
toaster.create({
type,
title,
description: 'Toast variant preview.',
});
};
return (
notify('default', 'Neutral')} variant="neutral">Neutral
notify('success', 'Success')} variant="success">Success
notify('info', 'Info')} variant="info">Info
notify('warning', 'Warning')} variant="warning">Warning
notify('error', 'Danger')} variant="danger">Danger
);
}
render( );
```
## Appearance
```tsx
const filled = createToaster({ placement: 'top-end' });
const filledLight = createToaster({ placement: 'top-end' });
const outlined = createToaster({ placement: 'top-end' });
const gradient = createToaster({ placement: 'top-end' });
function AppearanceDemo() {
const notify = (controller, label) => {
controller.success({
title: label,
description: 'Toast appearance preview.',
});
};
return (
notify(filled, 'Filled')}>Filled
notify(filledLight, 'Filled light')}>Filled light
notify(outlined, 'Outlined')}>Outlined
notify(gradient, 'Gradient')}>Gradient
);
}
render( );
```
## Positions
```tsx
const topStart = createToaster({ placement: 'top-start' });
const top = createToaster({ placement: 'top' });
const topEnd = createToaster({ placement: 'top-end' });
const bottomStart = createToaster({ placement: 'bottom-start' });
const bottom = createToaster({ placement: 'bottom' });
const bottomEnd = createToaster({ placement: 'bottom-end' });
const placements = [
['top-start', topStart],
['top', top],
['top-end', topEnd],
['bottom-start', bottomStart],
['bottom', bottom],
['bottom-end', bottomEnd],
];
function PositionDemo() {
const showToast = (placement, controller) => {
controller.info({
title: placement,
description: 'Rendered by the toaster with this placement.',
});
};
return (
{placements.map(([placement, controller]) => (
showToast(placement, controller)}>
{placement}
))}
{placements.map(([placement, controller]) => (
))}
);
}
render( );
```
## Persistent Toast
```tsx
const toaster = createToaster({ placement: 'top-end' });
function PersistentDemo() {
const showToast = () => {
toaster.info({
title: 'Persistent toast',
description: 'This toast stays visible until it is dismissed.',
duration: null,
});
};
return (
Show persistent
toaster.dismiss()}>Dismiss all
);
}
render( );
```
## Update Toast
```tsx
const toaster = createToaster({ placement: 'top-end' });
function UpdateDemo() {
const [toastId, setToastId] = React.useState(null);
const createToast = () => {
const id = toaster.loading({
title: 'Draft created',
description: 'Waiting for the next update.',
});
setToastId(id);
};
const updateToast = () => {
if (!toastId) return;
toaster.update(toastId, {
title: 'Draft saved',
description: 'The existing toast was updated.',
type: 'success',
duration: 3000,
});
setToastId(null);
};
return (
Create
Update
);
}
render( );
```
## Promise Toasts
```tsx
const toaster = createToaster({ placement: 'top-end' });
function PromiseDemo() {
const notify = () => {
const request = new Promise((resolve) => {
window.setTimeout(() => resolve({ pnr: 'TK42X7' }), 1200);
});
toaster.promise(request, {
loading: {
title: 'Saving booking',
description: 'Passenger details are being updated.',
},
success: (booking) => ({
title: 'Booking saved',
description: 'PNR ' + booking.pnr + ' is ready.',
}),
error: {
title: 'Booking could not be saved',
description: 'Please try again.',
},
});
};
return (
Save async
);
}
render( );
```
## Overlap
```tsx
const toaster = createToaster({ placement: 'top-end', duration: 5000 });
function OverlapDemo() {
const [count, setCount] = React.useState(0);
const showToast = () => {
const number = count + 1;
toaster.info({
title: 'Stacked toast ' + number,
description: 'The viewport is styled to overlap visible items.',
});
setCount(number);
};
return (
Show toast
);
}
render( );
```
## Max Visible
```tsx
const oneToaster = createToaster({
placement: 'top-start',
duration: 5000,
maxVisibleToasts: 1,
});
const fiveToaster = createToaster({
placement: 'top',
duration: 5000,
maxVisibleToasts: 5,
});
const eightToaster = createToaster({
placement: 'top-end',
duration: 5000,
maxVisibleToasts: 8,
});
function MaxVisibleDemo() {
const showToast = (label, controller) => {
for (let index = 1; index <= 10; index += 1) {
controller.info({
title: label + ' / Toast ' + index,
description: 'Queued items wait until a visible slot opens.',
});
}
};
return (
showToast('Max 1', oneToaster)}>Max 1
showToast('Max 5', fiveToaster)}>Max 5
showToast('Max 8', eightToaster)}>Max 8
);
}
render( );
```
## Duration
```tsx
const threeSecondToaster = createToaster({ placement: 'top-start', duration: 3000 });
const fiveSecondToaster = createToaster({ placement: 'top', duration: 5000 });
const eightSecondToaster = createToaster({ placement: 'top-end', duration: 8000 });
function DurationDemo() {
return (
threeSecondToaster.info({
title: '3 seconds',
description: 'This toast closes first.',
})
}
>
3s
fiveSecondToaster.info({
title: '5 seconds',
description: 'This toast stays a bit longer.',
})
}
>
5s
eightSecondToaster.info({
title: '8 seconds',
description: 'This toast stays visible the longest.',
})
}
>
8s
);
}
render( );
```
## Page Idle
```tsx
const toaster = createToaster({
duration: 8000,
pauseOnPageIdle: true,
placement: 'top-end',
});
function PageIdleDemo() {
const [seconds, setSeconds] = React.useState(0);
const [running, setRunning] = React.useState(false);
const showToast = () => {
setSeconds(8);
setRunning(true);
toaster.info({
title: 'Timer pauses while the page is idle',
description: 'Switch tabs and return before the countdown finishes.',
});
};
React.useEffect(() => {
if (!running || seconds <= 0) return;
const interval = window.setInterval(() => {
setSeconds((value) => Math.max(0, value - 1));
}, 1000);
return () => window.clearInterval(interval);
}, [running, seconds]);
React.useEffect(() => {
const handleVisibilityChange = () => {
setRunning(document.visibilityState === 'visible' && seconds > 0);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [seconds]);
return (
Show timed toast
{seconds}s
);
}
render( );
```
## External Close
```tsx
const toaster = createToaster({ placement: 'top-end' });
function ExternalCloseDemo() {
const [toastId, setToastId] = React.useState(null);
const showToast = () => {
const id = toaster.info({
title: 'Manual review required',
description: 'This notification stays visible until it is dismissed.',
duration: null,
});
setToastId(id);
};
const closeToast = () => {
if (!toastId) return;
toaster.dismiss(toastId);
setToastId(null);
};
return (
Show toast
Close from outside
);
}
render( );
```
## Custom Rendering
```tsx
const toaster = createToaster({ placement: 'bottom' });
function CustomRenderDemo() {
const showBoardingToast = () => {
toaster.clear();
toaster.info({
title: 'TK 1845',
description: 'Boarding starts at Gate A12.',
duration: null,
data: {
gate: 'A12',
group: 'Group 2',
time: '18:45',
},
});
};
return (
Show custom toast
{(toast) => {
const details = toast.data || {};
return (
Boarding pass
{toast.title}
{toast.description}
toaster.dismiss(toast.id)}
>
Dismiss
Gate
{details.gate}
Group
{details.group}
Time
{details.time}
);
}}
);
}
render( );
```
## API Reference
### Toaster {#toaster}
#### Props {#toaster-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------- |
| toaster | `ToasterController` | - | Toast controller returned by `createToaster`. |
| children | `React.ReactNode` | - | Optional render function for custom toast item rendering. |
| appearance | `ToastAppearance` | 'filled' | Alert appearance used by the default toast renderer. |
| closeLabel | `string` | 'Dismiss notification' | Accessible label for the default close control. |
| overlap | `boolean` | false | Stacks visible toasts and expands the stack on hover or focus. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| label | `string` | 'Notifications (F8)' | Accessible label for the toast viewport region. |
| hotkey | `string[]` | ['F8'] | Keyboard shortcut that focuses the toast viewport. |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Data attributes {#toaster-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-placement | Always | Reflects the toaster placement for viewport positioning. |
| data-overlap | When `overlap` is true | Enables the overlapped visual stack recipe. |
| data-expanded | When an overlapping toaster is hovered or focused | Indicates that the overlapped stack is expanded for interaction. |
### Toast {#toast}
#### Props {#toast-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------- |
| toast | `ToastData` | - | Toast item supplied by the headless toaster controller. |
| children | `React.ReactNode` | - | Optional custom toast content. When omitted, Toast renders the Takeoff Alert anatomy. |
| toaster | `ToasterController` | - | Controller used to pause, resume, and dismiss the toast. |
| appearance | `ToastAppearance` | 'filled' | Alert appearance used by the default toast renderer. |
| closeLabel | `string` | 'Dismiss notification' | Accessible label for the default close control. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| className | `string` | - | Appends custom classes to the root slot of this part. |
#### Data attributes {#toast-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | -------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-toast-id | Always | Stable toast item identifier used for layout motion. |
| data-status | Always | Reflects the toast lifecycle status. |
| data-type | Always | Reflects the headless toast type used for styling hooks. |
### Type Definitions {#toaster-type-definitions}
| Name | Definition |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ToasterController | `{ placement: ToastPlacement; maxVisibleToasts: number; create: (options: ToastOptions) => string; success: (options: ToastOptions) => string; error: (options: ToastOptions) => string; warning: (options: ToastOptions) => string; info: (options: ToastOptions) => string; loading: (options: ToastOptions) => string; update: (id: string, options: ToastUpdateOptions) => void; dismiss: (id?: string) => void; pause: (id?: string) => void; resume: (id?: string) => void; clear: () => void; destroy: () => void; promise: (promise: Promise, options: ToastPromiseOptions) => Promise; subscribe: (listener: () => void) => () => void; getSnapshot: () => ToastData[] }` |
| ToastAppearance | `'filled' \| 'filledLight' \| 'outlined' \| 'gradient'` |
| ToastData | `{ id: string; title?: ReactNode; description?: ReactNode; type: ToastType; duration: number \| null; createdAt: number; remaining: number \| null; status: ToastStatus; announcement: ToastAnnouncement; action?: ToastActionOptions \| undefined; dismissible: boolean; data?: unknown }` |
### Controller Values
| Option | Default | Behavior |
| ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------ |
| `placement` | `'bottom-end'` | Reflected on `data-placement` for viewport positioning. |
| `duration` | `5000` | Auto-dismiss duration for ordinary toasts. |
| `maxVisibleToasts` | `24` | Maximum number of toasts to display at once. Extra items stay queued; lower this for denser product screens. |
| `removeDelay` | `200` | Time a dismissing toast remains mounted for exit motion. |
| `pauseOnPageIdle` | `false` | Timers continue unless this option is enabled. |
## When to use
Any ephemeral, auto-dismissing notification fired imperatively (success/error/info/warning/loading, promise results, undo actions). Not for persistent inline status banners on the page — for those use an Alert component instead.
## Accessibility
- The viewport is an `aria-live` region; toasts are announced without stealing
focus.
- Press the `hotkey` (default `F8`) to move focus into the toast viewport;
`label` names that region.
- Each toast's default close control is labelled by `closeLabel`.
- Timers pause on hover/focus (and on page idle when `pauseOnPageIdle` is
enabled), giving users time to read and act.
---
# Upload
`Upload` is a compound, composition-first file control. The root owns the value
and validation; the dropzone, trigger, list, and per-file actions are parts you
compose — the parts you place are the parts you get.
It never uploads — no part of it does, `Upload.Submit` included. The transfer is
yours: start it from `onFileAccept` as files are accepted, or from
`Upload.Submit`'s `onClick` when the user sends the batch.
## Playground
```tsx
function PlaygroundDemo() {
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState('');
return (
Attachments
{ setFiles(next); setError(''); }}
onFilesReject={(rejections) => setError(rejections[0].file.name + ' was not accepted.')}
>
Choose a file or drag & drop it here.
Choose File
{error
? {error}
: Images or PDF, up to 5 MB each. }
);
}
render( );
```
## The value
An `UploadFile` points at a `File` rather than being one. `Upload` builds them
for picked and dropped files; for attachments already on the server, you build
them:
```tsx
const files = attachments.map(a => ({
id: a.id,
name: a.fileName,
size: a.bytes,
type: a.mimeType,
url: a.href,
thumbUrl: a.previewHref,
}));
```
| Field | Notes |
| ---------------------- | --------------------------------------------------------------------------- |
| `name`, `size`, `type` | The entry's own value wins; its `File` is the fallback |
| `file`, `url` | The two ways an entry can have content. With neither, `download` is dropped |
| `thumbUrl` | A picture _of_ the file, for the preview. Never downloaded |
| `status` | Omitted means `idle` |
## Validation
`accept`, `maxFileSize` (bytes), `maxFileCount`, and `multiple` go on the root.
Rejected files never enter `value` — `onFilesReject` reports them, carrying the
limit that broke rather than a message:
| `code` | Also carries |
| ------------------- | ------------------------------------- |
| `file-too-large` | `maxFileSize` |
| `file-invalid-type` | `accept` |
| `too-many-files` | `maxFileCount` (1 without `multiple`) |
`onFileAccept` reports only the entries that just entered the value;
`onValueChange` reports the whole next array, removals included. A duplicate of
a file already held fires neither — it is discarded before the limits are
checked, so re-offering a file a full upload already has is a no-op rather than
a `too-many-files` rejection.
`Upload.Dropzone` commits what was dropped after your own `onDrop` runs, and
does so unconditionally. It is the one part that does **not** read
`preventDefault()` as a veto: on a click, preventing the default is a deliberate
act, but on a drop it is the boilerplate that stops the browser navigating to
the dropped file, and most `onDrop` handlers write it by reflex — so honouring
it would turn that reflex into a zone that silently accepts nothing. To route a
drop through your own uploader, control the value: `onValueChange` (with
`value`) sees the batch before it is kept.
## Folders
`directory` turns the picker into a folder picker: choosing one takes every file
inside it, recursively. It implies `multiple` — a folder is a batch by
definition — and each `File` keeps its `webkitRelativePath`, which is where in
the tree it came from:
```tsx
function DirectoryDemo() {
const [files, setFiles] = React.useState([]);
return (
Choose a folder — every file inside it comes along.
Choose Folder
{/* The path is what the folder pick adds, so the row shows it in place of the bare name. */}
{(items) => items.map((item) => (
{item.file?.webkitRelativePath || item.name}
))}
);
}
render( );
```
Only the picker changes: `Upload.Dropzone` does not expand a dropped folder, so
a folder-first upload should say so in the zone's own copy rather than promise a
drop that never lands. Validation is unchanged — `accept` and `maxFileSize` run
per file, and `maxFileCount` counts the flattened batch rather than the folders
it arrived in.
## Status & progress
`status` and `progress` are consumer-owned — the component only displays them,
in `Upload.ItemContent`'s support line under the file name:
| `status` | Shows |
| ------------ | ----------------------------------------------------------------- |
| `idle` | nothing |
| `uploading` | spinner + `Uploading…`, plus the bar while `progress` is a number |
| `processing` | spinner + `Processing…`, never a bar |
| `completed` | check + `Completed` |
| `error` | warning + the entry's `error` |
```tsx
function StatusDemo() {
const [files, setFiles] = React.useState([
{ id: '1', name: 'annual-report.pdf', size: 2400000, type: 'application/pdf', status: 'completed' },
{ id: '2', name: 'cover-image.png', size: 640000, type: 'image/png', status: 'uploading', progress: 45 },
{ id: '3', name: 'keynote-deck.key', size: 12000000, status: 'processing' },
{ id: '4', name: 'raw-footage.mov', size: 88000000, type: 'video/quicktime', status: 'error', error: 'File too large for the server' },
]);
return (
);
}
render( );
```
Driving them is your job, and the two in-flight statuses split it: `uploading`
is the transfer, which knows its own percentage, and `processing` is whatever
the server does once the bytes have landed — a virus scan, a transcode, a parse
— which reports only that it is running. The demo below mocks that scan and
hands its verdict to a `Toaster` rather than to the row, because a result the
user may have to act on outlives the row it came from. Anything ending in `.zip`
or `.exe` comes back quarantined, so both outcomes are reachable:
```tsx
const toaster = createToaster({ placement: 'top-end' });
function ScanDemo() {
const [files, setFiles] = React.useState([]);
// Every step is a patch to one entry, so a second file scanning at the same
// time keeps its own status.
const patch = (id, next) =>
setFiles((current) => current.map((entry) => (entry.id === id ? { ...entry, ...next } : entry)));
const upload = (entries) => entries.forEach((entry) => {
// The transfer: the one status that draws a bar, because it is the one that
// knows how far along it is.
let percent = 0;
const transfer = window.setInterval(() => {
percent += 20;
if (percent < 100) return patch(entry.id, { status: 'uploading', progress: percent });
window.clearInterval(transfer);
// The bytes have landed and the server takes over. A virus scan reports
// that it is running, not how far it has got — so the bar goes away and
// 'processing' says what is happening instead.
patch(entry.id, { status: 'processing', progress: undefined });
window.setTimeout(() => {
const quarantined = /\.(zip|exe)$/i.test(entry.name);
if (quarantined) {
patch(entry.id, { status: 'error', error: 'Quarantined by the virus scan' });
toaster.error({
title: 'Threat found in ' + entry.name,
description: 'The file was quarantined and never stored.',
});
return;
}
patch(entry.id, { status: 'completed' });
toaster.success({
title: entry.name + ' is clean',
description: 'Virus scan passed — the file is stored.',
});
}, 2400);
}, 400);
});
return (
Choose a file to upload and scan it.
Choose File
);
}
render( );
```
Recovery is composition: replace the row's `Upload.ItemContent` and put the
entry back to `uploading`.
```tsx
{file.name} — {file.error}
retry(file)}>
Try again
```
Every word the component renders on its own comes from a root prop, one per
string — the same shape as `Stepper`'s `completedLabel` / `errorLabel` or
`Alert`'s `closeLabel`:
| Prop | Default | Where it shows |
| ----------------- | ------------------------ | --------------------------------------------------- |
| `uploadingLabel` | `Uploading…` | row support text while `uploading` |
| `processingLabel` | `Processing…` | row support text while `processing` |
| `completedLabel` | `Completed` | row support text on `completed` |
| `errorLabel` | `Failed` | row support text on `error` with no `error` message |
| `progressLabel` | `{name} upload progress` | the progress bar's accessible name |
| `downloadLabel` | `Download {name}` | `action="download"` accessible name |
| `removeLabel` | `Remove {name}` | `action="remove"` accessible name |
The first four name the state alone — the row already shows the file and that it
is an upload. The last three take a `{name}` placeholder so a translation can
move the file name inside the sentence
(`removeLabel="{name} dosyasını kaldır"`), which concatenating a verb and a name
cannot do. An entry's own `error` beats `errorLabel`; an action's own `label`
beats `downloadLabel` / `removeLabel`.
A label that resolves to nothing (`''`, or an `undefined` out of a partial
dictionary) is read differently by half: a blank status label drops the support
line, glyph included, while a blank `progressLabel` / `downloadLabel` /
`removeLabel` is ignored and the default stands — those exist only as accessible
names, and an icon-only control cannot go unnamed.
A localized app sets them once through the provider's `components` map rather
than at every call site:
```tsx
```
The file size is not in here. It is a number, so `Intl` writes it in the
runtime's locale — the unit and the decimal mark both (`1.5 MB` in English,
`1,5 MB` in Turkish).
## Sending the batch
`onFileAccept` sends each file the moment it is accepted. `Upload.Submit` is the
other model: the value fills up, the user reads the list back, and one press
sends what is in it. Nothing about the part performs the upload — it is a
`Button` that knows when sending makes sense, and disables itself when it does
not: while the value is empty, and while a batch is already going (any file
`uploading` or `processing`). The second is the double-submit the status
vocabulary makes visible — press again mid-transfer and the same files go twice
— so the part reads the status rather than asking you to wire
`disabled={inFlight}` yourself. The transfer is still yours; only the guard is
not.
Its place is beside the Trigger, in the zone: browse and send are one decision,
so they belong on one line. The zone stacks its children, so wrap the two in
`Upload.Actions` — a row that holds whatever you put in it, `8px` apart rather
than on the zone's own looser rhythm. It is a layout box and nothing else, so a
third control or a file count goes in it just as well, and a zone holding only a
Trigger needs none of it.
```tsx
function SubmitDemo() {
const [files, setFiles] = React.useState([]);
const patch = (id, next) =>
setFiles((current) => current.map((entry) => (entry.id === id ? { ...entry, ...next } : entry)));
// Nothing has moved before this runs — which is the whole difference from the
// onFileAccept demo above, where choosing a file was the same as sending it.
const send = () => files.forEach((entry) => {
let percent = 0;
const transfer = window.setInterval(() => {
percent += 25;
if (percent < 100) return patch(entry.id, { status: 'uploading', progress: percent });
window.clearInterval(transfer);
patch(entry.id, { status: 'completed', progress: undefined });
}, 300);
});
const inFlight = files.some((entry) => entry.status === 'uploading');
return (
Attach what you need — nothing is sent until you say so.
{/* No disabled guard here: Submit takes itself down while the value is
empty and again while a batch is in flight. The label is the part
that is yours — the component knows when sending is off, not what to
call it. */}
Choose Files
{inFlight ? 'Sending…' : 'Send'}
);
}
render( );
```
Which model to pick is a question about the value, not about the API: an
attachment that is only useful once the rest of a form is filled in belongs to a
batch, while a picture that has to appear in the row before anything else
happens does not. They compose — an `onFileAccept` that uploads to scratch
storage and a Submit that commits the batch is one flow, not two.
This is also the part `readOnly` treats differently from the others. It freezes
the trigger and drops the remove action, but Submit stays live, because a review
step that shows what is attached and lets you send it is the whole point of the
state. Only `disabled` — the inert root — takes it down.
## Per-file actions
Each control in `Upload.ItemActions` is an `Upload.ItemAction` — an icon
`Button` whose `action` names it and is mirrored as `data-action`. Two arrive
wired:
| `action` | Does | In read-only |
| -------------- | ---------------------------------------------------------------------------------- | --------------- |
| `"download"` | Saves the file — its `File` through an object URL, a preloaded entry via its `url` | Stays available |
| `"remove"` | Drops the file from `value` | Not rendered |
| any other name | Whatever your `onClick` does — `"preview"`, `"retry"`, … | Stays available |
Each action reads the file from its `Upload.Item`. `label` sets your own
`{name}` template, an explicit `aria-label` wins over both, and a built-in
behavior runs after your `onClick` unless you `preventDefault()` it.
Children are the override: on `Upload.ItemActions` they replace the default
download + remove pair, on an `Upload.ItemAction` they replace its glyph. Bare
actions on an `Upload.Item` are the shorthand for the first:
```tsx
```
All three kinds in one row below — a `"preview"` of your own beside the two that
arrive wired, and a remove that asks first. The veto is what makes that last one
possible, and it is synchronous: the built-in reads `defaultPrevented` as soon
as your handler returns, so a confirmation that resolves later has already
missed its chance to stop the removal.
```tsx
function ActionsDemo() {
const [files, setFiles] = React.useState([
{ id: '1', name: 'cover-photo.jpg', size: 840000, type: 'image/jpeg', url: '/img/takeoff-og.jpg' },
{ id: '2', name: 'brand-mark.svg', size: 12000, type: 'image/svg+xml', url: '/img/brand-mark.svg' },
]);
return (
{(items) => items.map((item) => (
// Bare actions: the shorthand, so the three below replace the default
// download + remove pair — order included.
{/* A name of its own: the verb comes from label, the glyph from
children, the behavior from onClick. */}
window.open(item.url, '_blank', 'noopener')}
>
{/* Built-in: saves the file, needs no wiring. */}
{/* The veto — synchronous, per the note above. */}
{
if (!window.confirm('Remove ' + item.name + '?')) event.preventDefault();
}}
/>
))}
);
}
render( );
```
The part is polymorphic, and the built-in save steps aside for an `href` on the
link form. It takes `as="a"` to do that — a plain button has nowhere to put the
attribute, so an `href` without it is ignored and the built-in save still runs.
`download` is only honoured same-origin, so a cross-origin link opens the file:
```tsx
```
## The preview
`Upload.Item` renders `Upload.ItemPreview` for you, in three branches per file:
| Branch | When | Renders |
| --------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| Image thumbnail | The entry has a `thumbUrl`, or is itself an image (picked, or a `url`) | ` `: that URL directly, or an object URL revoked on unmount |
| File-type icon | PDF, Word, Excel, PowerPoint, JPG, PNG, MP4, TXT, ZIP | The shipped Takeoff icon for that format |
| Extension badge | Anything else | The uppercased extension (`RTF`), or `FILE` |
Matched on extension first, MIME type second. `thumbUrl` outranks both other
branches and takes no MIME check; an image whose URL fails to load falls back to
the icon.
One entry per branch below — the two PDFs are the same format and differ only in
whether they carry a `thumbUrl`:
```tsx
function PreviewDemo() {
const [files, setFiles] = React.useState([
// Its own url is an image, so the row previews the file itself.
{ id: '1', name: 'cover-photo.jpg', size: 840000, type: 'image/jpeg', url: '/img/takeoff-og.jpg' },
// A picture of the file rather than the file: thumbUrl takes no MIME check,
// which is what puts a PDF on the image branch at all.
{ id: '2', name: 'quarterly-report.pdf', size: 2400000, type: 'application/pdf', thumbUrl: '/img/brand-mark.svg' },
// Same format, no thumb — the shipped icon for PDF.
{ id: '3', name: 'terms-and-conditions.pdf', size: 180000, type: 'application/pdf' },
// A format the icon set does not cover — the uppercased extension.
{ id: '4', name: 'meeting-notes.rtf', size: 24000, type: 'application/rtf' },
]);
return (
);
}
render( );
```
`thumbUrl` is a picture _of_ the file, so it is the only way a format the
browser cannot draw — a PDF's first page, a video's poster frame — reaches the
image branch. The download ignores it and still saves the file itself. The last
two rows carry neither a `file` nor a `url`, which is also why they have nothing
to download.
Compose the part to replace the default outright, or use `classNames` /
`slotProps` / `style` to keep it and restyle the box (`image`, `icon`,
`extension` are its slots).
## States
| Prop | Effect |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readOnly` | Files stay downloadable; adding and removing are blocked. The remove action is not rendered at all, `Upload.Trigger` is frozen in place, `Upload.Submit` stays live |
| `disabled` | Same anatomy, every control inert under `data-disabled`. The dropzone is recoloured, not faded |
| `invalid` | Danger treatment, usually inherited from `Field` |
All three resolve as `own prop ?? Field ?? false`. There is no `required` — a
hidden file input cannot carry it, so `` is the whole story.
The demo sets them on the `Field` rather than on the `Upload`, which is how a
form usually reaches them:
```tsx
const STATES = ['default', 'disabled', 'readOnly', 'invalid'];
function StatesDemo() {
const [state, setState] = React.useState('default');
const [files, setFiles] = React.useState([
// Both preloaded and both downloadable, so the only thing that changes
// between the states below is the state itself.
{ id: '1', name: 'cover-photo.jpg', size: 840000, type: 'image/jpeg', url: '/img/takeoff-og.jpg' },
{ id: '2', name: 'brand-mark.svg', size: 12000, type: 'image/svg+xml', url: '/img/brand-mark.svg' },
]);
return (
{STATES.map((name) => (
setState(name)}
>
{name}
))}
{/* Set on the Field, not on the Upload: all three resolve as
own prop ?? Field ?? false, and the Field is the usual source. */}
Attachments
Choose a file or drop it here.
Choose File
{state === 'invalid'
? At least one attachment has to be a PDF.
: Watch the trigger, the remove action, and the zone. }
);
}
render( );
```
`readOnly` and `disabled` differ in shape, not just in weight: read-only takes
the remove action out of every row while leaving download and the files
themselves, because a view mode with a dead remove button is worse than one
without it — and it freezes the trigger in place rather than dropping it, since
a zone with no browse button is a dashed box promising a drop that never lands.
`disabled` changes nothing about the anatomy; every control in it just goes
inert.
Paint your own dropzone content with `--tk-upload-dropzone-mark` and
`--tk-upload-dropzone-support`, or it stays lit while the zone goes quiet.
## Accessibility
- The trigger and the per-file actions are real, focusable ``s.
Icon-only actions carry a file-specific `aria-label` ("Remove report.pdf").
- Inside a `Field`, the root is the labelled region (`role="group"`), named by
`Field.Label` and described by `Field.Description` / `Field.ErrorMessage`.
Your own ARIA always wins.
- The native file input is visually hidden but reachable through the trigger.
- Removing a row moves focus before the row unmounts: to the next row's matching
action, or to the row above when the last one goes, or to `Upload.Trigger`
when the list empties. An unmounted control would otherwise hand focus back to
the document body, restarting every subsequent `Tab` from the top of the page.
- Parts rendered through `as` stay operable from the keyboard — `Enter` and
`Space` run the same built-in behavior a click does — and an `as="a"` trigger
or action is announced as a link while it has an `href` to follow.
## API Reference
### Upload {#upload}
#### Props {#upload-props}
| Name | Type | Default | Description |
| ------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| value | `UploadFile[]` | - | Committed files (controlled). Pair with `onValueChange`. |
| defaultValue | `UploadFile[]` | - | Initial files for uncontrolled usage. |
| accept | `string` | - | Acceptable file types — comma-separated MIME types (`image/*`) and/or extensions (`.pdf`). |
| multiple | `boolean` | false | Allow selecting/holding more than one file. When `false`, a new selection replaces the current file. |
| directory | `boolean` | false | Browse folders instead of single files: the picker takes a directory and every file inside it (recursively), each keeping its `webkitRelativePath` (e.g. `reports/2024/q1.pdf`) for rebuilding the tree. Implies `multiple`. Only affects the picker — dropping a folder on `Upload.Dropzone` does not expand it. |
| maxFileSize | `number` | - | Maximum size per file, in bytes. Larger files are rejected. |
| maxFileCount | `number` | - | Maximum number of files (only meaningful with `multiple`). Extra files are rejected. |
| uploadingLabel | `string` | 'Uploading…' | Row support text while a file transfers. Localize per upload; an empty string drops the status line, glyph included. |
| processingLabel | `string` | 'Processing…' | Row support text while the server works on a file that has landed. Localize per upload; an empty string drops the status line, glyph included. |
| completedLabel | `string` | 'Completed' | Row support text once a file is done. Localize per upload; an empty string drops the status line, glyph included. |
| errorLabel | `string` | 'Failed' | Row support text for a failure that reports no `error` message of its own — an entry's own `error` always wins. Localize per upload; an empty string drops the status line, glyph included. |
| progressLabel | `string` | '{name} upload progress' | Accessible name for a row's progress bar, as a template: `{name}` stands for the file's name, so several bars uploading at once stay distinguishable. An empty string counts as unset — the bar cannot go unnamed. |
| downloadLabel | `string` | 'Download {name}' | Accessible name for the built-in `action="download"`, as a `{name}` template. An `Upload.ItemAction`'s own `label` wins over it, and an empty string counts as unset — an icon-only button cannot go unnamed. |
| removeLabel | `string` | 'Remove {name}' | Accessible name for the built-in `action="remove"`, as a `{name}` template. An `Upload.ItemAction`'s own `label` wins over it, and an empty string counts as unset — an icon-only button cannot go unnamed. |
| disabled | `boolean` | - | Disables the whole control. Also inherited from a surrounding `Field`. |
| readOnly | `boolean` | - | Read-only: files render and remain downloadable, but adding/removing is blocked. Also inherited from `Field`. |
| invalid | `boolean` | - | Marks the control invalid — mirrored as `data-invalid` for the recipe's danger styling. Also inherited from `Field`. |
| classNames | `Partial>` | - | Per-slot class name overrides. |
| slotProps | `Partial>>` | - | Per-slot HTML attribute overrides. |
| className | `string` | - | Appends custom classes to the root slot. |
#### Events {#upload-events}
| Name | Type | Default | Description |
| ---------------------------------- | ------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onValueChange | `(files: UploadFile[]) => void` | - | Called with the next file array after a selection, drop, or removal. |
| onFileAccept | `(files: UploadFile[]) => void` | - | Called with the entries that just entered the value — the files wrapped this batch rather than the whole array, which is what a consumer starts its own upload from. Files dropped as duplicates of ones already held are not reported. |
| onFilesReject | `(rejections: UploadRejection[]) => void` | - | Called with every file rejected by validation (type, size, or count). |
#### Data attributes {#upload-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-disabled | When disabled (own prop or inherited from a surrounding `Field`) | Styling hook for the disabled state. |
| data-readonly | When read-only (own prop or `Field`) | Styling hook for the read-only state — takes the zone out of the drag flow. |
| data-invalid | When invalid (own prop or `Field`) | Styling hook for the invalid state (danger treatment). |
### Upload.Dropzone {#upload-dropzone}
#### Data attributes {#upload-dropzone-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-drag-state | While a payload is dragged over (`accept` \| `reject`, by matching the dragged type against `accept`) | Styling hook distinguishing an acceptable from a rejected drag. Unstyled by default — the recipe ships no drag treatment. |
### Upload.Actions {#upload-actions}
#### Data attributes {#upload-actions-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.Trigger {#upload-trigger}
#### Props {#upload-trigger-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| children | `React.ReactNode` | - | Trigger label (e.g. "Choose file"). |
| appearance | `ButtonAppearance` | 'outlined' | Button appearance. Quieter than Button's own default, and the treatment `Upload.Submit` takes too — the two stand beside each other in the zone, so they read as one pair. Re-point it on a Trigger that stands alone. |
| variant | `ButtonVariant` | 'neutral' | Button color variant. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| className | `string` | - | Appends custom classes to the root slot. |
#### Data attributes {#upload-trigger-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.Submit {#upload-submit}
#### Props {#upload-submit-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| children | `React.ReactNode` | - | Submit label (e.g. "Upload"). Wire the actual upload through `onClick`. |
| appearance | `ButtonAppearance` | 'outlined' | Button appearance. The Trigger's, not Button's own: the two stand beside each other in the zone, so they read as one pair rather than as two weights. Re-point it where the send is the page's primary action. |
| variant | `ButtonVariant` | 'neutral' | Button color variant. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| className | `string` | - | Appends custom classes to the root slot. |
#### Data attributes {#upload-submit-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-disabled | While the value is empty, while any file is `uploading` or `processing`, or when the root (or the part) is disabled | Emitted by the underlying Button. The in-flight case is the double-submit guard — pressing again mid-transfer would send the same files twice — so it is the part's own, not something to wire through `disabled`. |
### Upload.List {#upload-list}
#### Data attributes {#upload-list-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.Item {#upload-item}
#### Props {#upload-item-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| file | `UploadFile` | - | The file this row renders. Also what its `Upload.ItemAction` children read. |
| children | `React.ReactNode` | - | Per-file action controls (`Upload.ItemAction`), wrapped in a default `Upload.ItemActions` — and replacing its default download + remove pair rather than joining it. A composed `Upload.ItemPreview`, `Upload.ItemContent`, or `Upload.ItemActions` among them is hoisted into its own region, replacing that default. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| className | `string` | - | Appends custom classes to the root slot. |
#### Data attributes {#upload-item-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-status | Always (`idle` \| `uploading` \| `processing` \| `completed` \| `error`) | Per-file status styling hook (consumer-driven `UploadFile.status`). |
### Upload.ItemContent {#upload-item-content}
#### Data attributes {#upload-item-content-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.ItemPreview {#upload-item-preview}
#### Data attributes {#upload-item-preview-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.ItemActions {#upload-item-actions}
#### Data attributes {#upload-item-actions-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | ------------ | ----------------------------------------------------- |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
### Upload.ItemAction {#upload-item-action}
#### Props {#upload-item-action-props}
| Name | Type | Default | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| action | `"download" \| "remove" \| (string & {})` | - | Names the action, mirrored as `data-action` whatever the name is. Two names arrive wired, each with its own icon and label: `'download'` saves the row's file (its `File` through an object URL, a preloaded entry through its `url`) and stays available in read-only; `'remove'` drops the file from `value` and is not rendered at all in read-only. Any other name — `'preview'`, `'retry'`, `'share'` — is yours: the behavior comes from `onClick`, the glyph from `children`, the wording from `label`. |
| label | `string` | the root's copy for the `action` (`downloadLabel` / `removeLabel`) | Accessible name for the action, as a template: `{name}` stands for the file's name, so `"Preview {name}"` gives `aria-label="Preview report.pdf"` and icon-only actions stay labelled per file. An explicit `aria-label` wins over it, and an empty one counts as unset, so a built-in action falls back to the root's copy rather than losing its name. |
| children | `React.ReactNode` | - | Action content — typically an icon. Defaults to the `action`'s own glyph. |
| appearance | `ButtonAppearance` | 'outlined' | Button appearance. Defaults to the row's shape — a small, quiet icon button beside the file's details — rather than Button's own default, so a text-only remove or a danger-coloured one is a re-point, not a rebuild. |
| variant | `ButtonVariant` | 'neutral' | Button color variant. |
| size | `ButtonSize` | 'small' | Button size scale. |
| classNames | `Partial>` | - | |
| slotProps | `Partial>>` | - | |
| className | `string` | - | Appends custom classes to the root slot. |
#### Data attributes {#upload-item-action-data-attributes}
| Attribute | Applied when | Purpose |
| ----------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ |
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-action | When `action` is set (any name; `download` \| `remove` are the wired pair) | Names the action, for styling one action out of a row. |
### Type Definitions {#upload-type-definitions}
| Name | Definition |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| UploadFile | `{ id: string; file?: File; name?: string; size?: number; type?: string; url?: string; thumbUrl?: string; status?: UploadFileStatus; progress?: number; error?: string }` |
| UploadRejection | `\| { file: File; code: 'file-invalid-type'; /** The `accept` list the file failed to match. \*/ accept: string; } \| { file: File; code: 'file-too-large'; /** The `maxFileSize` (bytes) the file exceeded. _/ maxFileSize: number; } \| { file: File; code: 'too-many-files'; /\*\* How many files were allowed — `maxFileCount`, or 1 without `multiple`. _/ maxFileCount: number; }` |
| ButtonAppearance | `'filled' \| 'filledLight' \| 'outlined' \| 'text'` |
| ButtonVariant | `'primary' \| 'secondary' \| 'neutral' \| 'info' \| 'success' \| 'danger' \| 'warning' \| 'white' \| 'black'` |
| UploadItemContentSlot | `'root' \| 'name' \| 'size' \| 'status' \| 'progress'` |
| UploadItemPreviewSlot | `'root' \| 'image' \| 'icon' \| 'extension'` |
| UploadItemPreviewSlotProps | `SlotPropsMap & { image?: ImgHTMLAttributes; }` |
| ButtonSize | `'small' \| 'base' \| 'large'` |
## When to use
attaching files to a form, a review-then-send batch, an avatar/image picker. Not this — use `takeoff-input` for plain text entry, and wrap in `takeoff-field` when you need a label, helper text, or error copy.
---
# Components
Browse the shipped `@takeoff-ui/react-spar` component surface. Each reference
includes live examples, public props, compound parts, stable data attributes,
and accessibility guidance where the component owns interaction behavior.
## Actions & form controls
| Component | Use it for |
| ------------------------------------- | ---------------------------------------------------------------------- |
| [Button](/docs/components/button) | Triggering actions with loading, toggle, and icon support. |
| [Checkbox](/docs/components/checkbox) | Selecting multiple options or representing a mixed state. |
| [Dropdown](/docs/components/dropdown) | Presenting contextual action menus from a trigger. |
| [Input](/docs/components/input) | Building text inputs with labels, affixes, icons, chips, and actions. |
| [Label](/docs/components/label) | Labeling controls, sections, and compact metadata. |
| [Radio](/docs/components/radio) | Choosing one option from a mutually exclusive set. |
| [Select](/docs/components/select) | Choosing from a keyboard-accessible list with typeahead. |
| [Switch](/docs/components/switch) | Turning a single setting on or off. |
| [Upload](/docs/components/upload) | Selecting and validating files with browse, drag-and-drop, and a list. |
## Navigation & disclosure
| Component | Use it for |
| ----------------------------------------- | ---------------------------------------------------------- |
| [Accordion](/docs/components/accordion) | Organizing related details into collapsible sections. |
| [Breadcrumb](/docs/components/breadcrumb) | Showing the current location within a hierarchy. |
| [Tabs](/docs/components/tabs) | Switching between related panels without leaving the task. |
## Feedback & status
| Component | Use it for |
| ------------------------------------- | ------------------------------------------------------------------------ |
| [Alert](/docs/components/alert) | Presenting contextual success, warning, information, or danger messages. |
| [Badge](/docs/components/badge) | Displaying compact status, count, or category labels. |
| [Chip](/docs/components/chip) | Representing filters, selections, and removable values. |
| [Progress](/docs/components/progress) | Showing determinate task or process completion. |
| [Skeleton](/docs/components/skeleton) | Reserving layout while content is loading. |
| [Spinner](/docs/components/spinner) | Communicating indeterminate loading work. |
| [Stepper](/docs/components/stepper) | Guiding users through a sequenced multi-step flow. |
| [Toast](/docs/components/toast) | Showing transient confirmations, alerts, and background task updates. |
| [Tooltip](/docs/components/tooltip) | Adding short contextual information on hover or focus. |
## Content & overlays
| Component | Use it for |
| ----------------------------------- | -------------------------------------------------------------- |
| [Card](/docs/components/card) | Grouping related content, summaries, and actions. |
| [Dialog](/docs/components/dialog) | Handling focused forms, confirmations, and blocking decisions. |
| [Divider](/docs/components/divider) | Separating content sections with an optional aligned label. |
| [Drawer](/docs/components/drawer) | Showing navigation, filters, or details over the current page. |
| [Popover](/docs/components/popover) | Displaying additional content or actions in a floating panel. |
| [Table](/docs/components/table) | Scanning, sorting, filtering, and paginating structured data. |
Start with [Composition & styling](/docs/foundations/composition-styling) to
learn the shared compound, slot, and customization model.
---
# React Hook Form
Build forms by letting React Hook Form manage values, validation, submission,
and field state. Use Takeoff `Field` components for the visible field anatomy:
label, description, invalid state, and error message.
## Demo
The form below shows the field structure and error display behavior.
```tsx
function BookingRequestDemo() {
const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
return (
);
}
render( );
```
## Approach
Use React Hook Form's `useForm` hook to create the form instance. Use
`Controller` when a component is composed or controlled, because the render
function gives you a clear place to map form state into Takeoff props.
- `field` contains bindings such as `name`, `value`, `onChange`, `onBlur`, and
`ref`.
- `fieldState` contains the field's validation state, including `invalid` and
`error`.
- `Field invalid={fieldState.invalid}` drives the field-level visual state.
- `aria-invalid={fieldState.invalid}` belongs on the interactive control.
## Anatomy
```tsx
(
Request title
Use a short, specific title.
{fieldState.invalid ? (
{fieldState.error?.message}
) : null}
)}
/>
```
## Form
### Create a Form Schema
Define the shape and validation messages for the form. The example below uses
Zod, but the field anatomy stays the same with any resolver supported by React
Hook Form.
```tsx
import * as z from 'zod';
const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
```
### Set Up the Form
Create the form instance with `useForm`, pass the schema resolver, and define
default values for every field.
```tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
type FormValues = z.infer;
export function BookingRequestForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
function onSubmit(values: FormValues) {
console.log(values);
}
return (
);
}
```
### Build the Form
Use `Controller` for each field and map `fieldState` into `Field`.
```tsx
import { Controller } from 'react-hook-form';
import { Button, Checkbox, Field, Input, Select } from '@takeoff-ui/react-spar';
export function BookingRequestForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
return (
);
}
```
### Done
When the user submits, React Hook Form validates the data and calls your submit
handler with the validated values. Invalid fields receive `fieldState.invalid`,
and each field can render its own `Field.ErrorMessage`.
## Validation
### Client-Side Validation
Schema validation lives in the `resolver` option. Field rendering does not need
to know where the error came from; it only reads `fieldState`.
```tsx
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
});
```
### Validation Modes
React Hook Form supports different validation modes:
| Mode | Description |
| ----------- | ---------------------------------------------------- |
| `onSubmit` | Validate on submit. This is the default. |
| `onBlur` | Validate when a field loses focus. |
| `onChange` | Validate on every change. |
| `onTouched` | Validate after the first blur, then on every change. |
| `all` | Validate on both blur and change. |
```tsx
const form = useForm({
resolver: zodResolver(formSchema),
mode: 'onBlur',
});
```
## Displaying Errors
Display errors next to the field they belong to. For styling and accessibility:
- Pass `fieldState.invalid` to `Field invalid`.
- Pass `fieldState.invalid` to the interactive control with `aria-invalid`.
- Render `Field.ErrorMessage` only when the field is invalid.
```tsx
(
Request title
{fieldState.invalid ? (
{fieldState.error?.message}
) : null}
)}
/>
```
## Working with Different Field Types
The field anatomy stays the same. Only the value mapping changes:
| Component | Mapping |
| ------------- | -------------------------------------------------------------------------------------------------------------- |
| `Input.Field` | Spread `field`, then add `aria-invalid={fieldState.invalid}`. |
| `Select` | Use `value={field.value}` and `onChange={field.onChange}` on `Select`; put `aria-invalid` on `Select.Trigger`. |
| `Checkbox` | Use `checked={field.value}` and `onChange={field.onChange}`. |
| `Switch` | Use `checked={field.value}` and `onChange={field.onChange}`. |
| `Radio` | Use `value={field.value}` and `onChange={field.onChange}` on the `Radio` root. |
```tsx
(
Cabin
Economy
Business
{fieldState.invalid ? (
{fieldState.error?.message}
) : null}
)}
/>
```
## Array Fields
Use `useFieldArray` when the form needs a repeatable set of fields such as
passengers, baggage items, or contact methods. Keep the array state in React
Hook Form, then render one `Field` per item.
The live demo below mirrors the same add, remove, reset, and submit flow as the
code sample.
```tsx
function PassengerArrayDemo() {
const formSchema = z.object({
passengers: z
.array(
z.object({
fullName: z.string().min(1, 'Passenger name is required.'),
}),
)
.min(1, 'Add at least one passenger.'),
});
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
passengers: [{ fullName: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'passengers',
});
return (
);
}
render( );
```
---
# TanStack Form
Build forms by letting TanStack Form manage values, validation, submission, and
field state. Use Takeoff `Field` components for the visible field anatomy:
label, description, invalid state, and error message.
## Demo
The form below shows the field structure and error display behavior.
```tsx
function BookingRequestDemo() {
const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async () => {},
});
return (
{(field) => {
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];
const message = typeof error === 'string' ? error : error?.message;
return (
Request title
field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
placeholder="Change return flight"
autoComplete="off"
/>
{invalid ? {message} : Use a short, specific title. }
);
}}
{(field) => {
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];
const message = typeof error === 'string' ? error : error?.message;
return (
Cabin
Economy
Business
{invalid ? {message} : null}
);
}}
{(field) => {
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];
const message = typeof error === 'string' ? error : error?.message;
return (
I accept the booking terms
{!invalid ? Required before submission. : null}
{invalid ? {message} : null}
);
}}
form.reset()}>
Reset
Continue
);
}
render( );
```
## Approach
Use TanStack Form's `useForm` hook to create the form instance. Use `form.Field`
when a component is composed or controlled, because the render function gives
you a clear place to map form state into Takeoff props.
- `field.state.value` contains the current field value.
- `field.handleChange` and `field.handleBlur` connect the component to the form.
- `field.state.meta.errors` contains validation messages.
- `Field invalid={...}` drives the field-level visual state.
- `aria-invalid={...}` belongs on the interactive control.
## Anatomy
```tsx
{field => {
const invalid =
field.state.meta.isTouched && !!field.state.meta.errors.length;
return (
Request title
field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
autoComplete="off"
/>
Use a short, specific title.
{invalid ? (
{String(field.state.meta.errors[0])}
) : null}
);
}}
```
## Form
### Create a Form Schema
Define the shape and validation messages in your validators. TanStack Form lets
you attach validation where the field lives, or provide a form-level schema with
a Standard Schema library such as Zod.
```tsx
import * as z from 'zod';
const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
type FormValues = z.infer;
```
### Set Up the Form
Create the form instance with `useForm`, pass the schema to `validators`, and
handle submit at the form level.
```tsx
import { useForm } from '@tanstack/react-form';
import * as z from 'zod';
export function BookingRequestForm() {
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});
return (
);
}
```
### Build the Form
Use `form.Field` for each field and map its state into `Field`.
```tsx
import { useForm } from '@tanstack/react-form';
import { Button, Checkbox, Field, Input, Select } from '@takeoff-ui/react-spar';
export function BookingRequestForm() {
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});
return (
!value ? 'Request title is required' : undefined,
}}
>
{field => {
const invalid =
field.state.meta.isTouched && !!field.state.meta.errors.length;
return (
Request title
field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
autoComplete="off"
/>
{invalid ? (
{String(field.state.meta.errors[0])}
) : null}
);
}}
(!value ? 'Select a cabin' : undefined),
}}
>
{field => {
const invalid = !!field.state.meta.errors.length;
return (
Cabin
Economy
Business
{invalid ? (
{String(field.state.meta.errors[0])}
) : null}
);
}}
value ? undefined : 'You must accept the terms',
}}
>
{field => {
const invalid = !!field.state.meta.errors.length;
return (
I accept the booking terms
{invalid ? (
{String(field.state.meta.errors[0])}
) : null}
);
}}
Continue
);
}
```
### Done
When the user submits, TanStack Form validates the data and calls your submit
handler with the final values. Invalid fields receive errors through
`field.state.meta.errors`, and each field can render its own
`Field.ErrorMessage`.
## Validation
### Client-Side Validation
Validation lives in the field validators. Field rendering does not need to know
where the error came from; it only reads `field.state.meta.errors`.
```tsx
(!value ? 'Request title is required' : undefined),
}}
>
{/* field render */}
```
### Schema Validation with Zod
TanStack Form supports Standard Schema validators, so you can pass a Zod schema
directly to the form or to an individual field validator. This keeps the data
shape and validation rules in one place.
```tsx
import * as z from 'zod';
const formSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});
```
### Validation Modes
TanStack Form lets you choose when each validator runs:
| Mode | Description |
| ---------- | --------------------------------------- |
| `onChange` | Validate on every change. |
| `onBlur` | Validate when a field loses focus. |
| `onSubmit` | Validate when the form is submitted. |
| `onMount` | Validate when the form or field mounts. |
```tsx
(!value ? 'Email is required' : undefined),
onSubmit: ({ value }) =>
!value.includes('@') ? 'Enter a valid email' : undefined,
}}
>
{/* field render */}
```
## Displaying Errors
Display errors next to the field they belong to. For styling and accessibility:
- Compute `invalid` from the field meta.
- Pass that value to `Field invalid`.
- Pass the same value to the interactive control with `aria-invalid`.
- Render `Field.ErrorMessage` only when the field is invalid.
```tsx
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];
return (
Request title
field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
/>
{invalid ? {error.message} : null}
);
```
If you use field-level string validators, render
`String(field.state.meta.errors[0])`. If you use a schema validator such as Zod,
render the structured error message.
## Working with Different Field Types
The field anatomy stays the same. Only the value mapping changes:
| Component | Mapping |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `Input.Field` | Use `value={field.state.value}` and pass `event.target.value` to `field.handleChange`. |
| `Select` | Use `value={field.state.value}` and `onChange={field.handleChange}` on `Select`; put `aria-invalid` on `Select.Trigger`. |
| `Checkbox` | Use `checked={field.state.value}` and `onChange={field.handleChange}`. |
| `Switch` | Use `checked={field.state.value}` and `onChange={field.handleChange}`. |
| `Radio` | Use `value={field.state.value}` and `onChange={field.handleChange}` on the `Radio` root. |
## Array Fields
Use `mode="array"` when the form needs a repeatable set of fields such as
passengers, baggage items, or contact methods. Keep the array state in TanStack
Form, then render one `Field` per item.
The live demo below mirrors the same add, remove, reset, and submit flow as the
code sample.
```tsx
function PassengerArrayDemo() {
const form = useForm({
defaultValues: {
passengers: [{ fullName: '' }],
},
onSubmit: async () => {},
});
return (
{(passengersField) => (
<>
{passengersField.state.value.map((_, index) => {
const canRemove = passengersField.state.value.length > 1;
return (
(!value.trim() ? 'Passenger name is required.' : undefined),
}}
>
{(field) => {
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];
const message = typeof error === 'string' ? error : error?.message;
return (
Passenger {index + 1}
field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
autoComplete="name"
placeholder="Ada Lovelace"
/>
passengersField.removeValue(index)}
className="inline-flex h-5 w-5 justify-center"
>
{invalid ? {message} : null}
);
}}
);
})}
passengersField.pushValue({ fullName: '' })}
className="w-full"
>
Add passenger
Save passengers
form.reset({ passengers: [{ fullName: '' }] })}
>
Reset
>
)}
);
}
render( );
```
## Notes
- Keep validation and value transformation in TanStack Form.
- Pass visual validation state to `Field invalid`.
- Put `aria-invalid` on the interactive control.
- Prefer `Field.ErrorMessage` for new examples. `Field.ErrorMessage` remains
supported.