`s with an X that only a pointer can reach, and
the multi-select becomes keyboard-inaccessible at exactly the point where it
holds the most state. `ComboboxChips`, `ComboboxChip` and `ComboboxChipRemove`
are focusable, walk with the arrows, and delete with Backspace or Delete - and
Backspace on an empty input removes the last one.
`ComboboxChips` takes plain children, not a render function. It is
`ComboboxValue` inside it that maps the chosen values, because it is the part
that knows what they are.
### Two behaviours worth knowing
`ComboboxEmpty` renders only when nothing matched, and announces itself
politely. Its element stays mounted for that announcement to work, so it must
not be hidden with `display: none` or removed conditionally - which is why it
is a component rather than a `{items.length === 0 && …}` in the product.
`ComboboxClear` is not a "clear what I typed" button. In single-selection mode
Base UI shows it only once a value has been *selected*; the query clears itself
when the popup closes.
### Combobox or Select
**Combobox** when the options are too many to scan - a country, a tag, a
person - or when the reader already knows the answer and typing it is faster
than finding it. Also when a multi-select has to *show* what is chosen, since
this is the one with chips.
**Select** when the list is short enough to read: a status, a currency, a
priority. A search box over five options is noise, and it asks the reader to
type where they could have pointed.
```tsx
import {
Combobox,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxPopup,
} from '@/components/ui/combobox'
{t('noCountries')}
{(item) => (
{item}
)}
```
With chips, for the `multiple` case:
```tsx
{(value) =>
value.map((tag) => (
{tag}
))
}
{/* popup as above */}
```
## Props
### `Combobox` (root)
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `items` | `Array` | | What gets filtered |
| `multiple` | `boolean` | `false` | Turns `value` into an array, and makes chips meaningful |
| `filter` | `function \| null` | `Intl.Collator` | `null` to filter elsewhere |
| `value` / `onValueChange` | | | Controlled; `defaultValue` for uncontrolled |
| `filteredItems` | `Array` | | When the product filters it itself |
### `ComboboxInput`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | Matches Input's heights |
| `className` | `string` | | Merged so the caller wins a conflict |
### `ComboboxPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | A floor; it matches the input's width |
| `side` | `top \| right \| bottom \| left` | `bottom` | A preference - Base UI flips it when it does not fit |
| `align` | `start \| center \| end` | | Alignment along that side |
| `sideOffset` | `number` | `4` | Distance from the input |
| `container` | `Element \| Ref` | document body | Where to portal to |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
`ComboboxList`, `ComboboxItem`, `ComboboxItemIndicator`, `ComboboxEmpty`,
`ComboboxStatus`, `ComboboxTrigger`, `ComboboxIcon`, `ComboboxClear`,
`ComboboxInputGroup`, `ComboboxChips`, `ComboboxChip`, `ComboboxChipRemove`,
`ComboboxValue`, `ComboboxGroup` and `ComboboxGroupLabel` pass their props to
Base UI unchanged. `comboboxPopupVariants` and `comboboxItemVariants` are
Select's, re-exported: the two dropdowns are the same object seen twice, and
two `cva` calls that started identical do not stay that way.
---
# CommandPalette
Source: https://lacodda.github.io/dowel/components/command-palette
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#command-palette
Open and listing everything, narrowed by a query, and with nothing matching.
## What is yours and what is ours
This is the part worth reading before the props.
**dowel supplies the box, the filtering and the keyboard.** The dim over the
page, the box in the upper third of it, the field, the narrowing as you type,
the highlight, the arrows, the type-ahead, Enter, Escape, and the
announcement of all of it to a screen reader.
**The product supplies what makes it that product's palette:**
- **the items** — what is in the box at all, and what each row shows;
- **the grouping** — whether commands, recent files and settings are three
labelled groups or one flat list;
- **what running one does** — every word, and every consequence.
That division is why `items` is `unknown[]` rather than a list of strings. A
palette lists commands, works, settings and recent files in the same box; a
type admitting only strings would push every product into the same
stringly-typed workaround, mapping identifiers back to objects on the way out.
It is also why nothing here has a word of its own. The empty state has no
default text, the field has no default placeholder, and the popup has no
default name — those are the product's, in the product's language, and a
default here would ship English to every reader who does not read English with
no way for the product to reach it.
## Notes
### It is a Combobox, not a Dialog with a field in it
The obvious build is a Dialog containing a search input. That is the wrong
arrangement, and it fails quietly: a screen reader is told about a dialog that
happens to contain a text box, with no stated relationship between what is
typed and the list that changes underneath.
Base UI's own arrangement is the other way round — put the input **inside** the
popup and the popup becomes `role="dialog"` on its own, while the input is
still announced as the `role="combobox"` that owns the list. Both are true at
once, which is the whole reason this component exists rather than being
assembled per product.
Everything that follows from that is Base UI's and is not reimplemented here:
the filtering with `Intl.Collator` (so accents and case behave the way a reader
in that language expects, rather than the way `toLowerCase` does), the
highlight, the arrow keys, and the type-ahead.
### Give the popup a name
The one thing the arrangement costs. A Dialog takes its name from a visible
title; a palette has no title — it opens straight onto a field — so there is
nothing for the popup to point `aria-labelledby` at, and an unnamed dialog is
one a screen reader announces as nothing.
So `aria-label` on `CommandPalettePopup` is **required by the type**, not
merely recommended: the component cannot supply it for the reason above, and a
palette that forgets it fails to compile rather than shipping a dialog that
announces itself as nothing.
### It is opened from somewhere else
`CommandPalette` is controlled by `open` / `onOpenChange`, because what opens a
palette is a keystroke bound elsewhere in the application — which is what
[`useShortcut`](/dowel/components/shortcut/) is for. The popup is anchored to
the viewport rather than to a trigger, since it has no trigger to point at.
### The arrows walk through the field
Pressing Down past the last row does not jump straight back to the first: the
highlight comes off the list and back onto the query, and the next Down enters
at the top again. That is right for a palette — the field is where the reader
edits what they typed, so walking off the end should reach it rather than skip
it — but it is one stop more than the list has, which is worth knowing before
it looks like a bug.
Nothing in the list ever takes focus. The highlight is `aria-activedescendant`
on the input throughout, which is what lets typing carry on between arrow
presses.
```tsx
import { useShortcut } from '@/components/ui/shortcut'
import {
CommandPalette,
CommandPaletteEmpty,
CommandPaletteInput,
CommandPaletteItem,
CommandPaletteList,
CommandPalettePopup,
CommandPaletteRow,
} from '@/components/ui/command-palette'
const [open, setOpen] = useState(false)
useShortcut(['Mod', 'K'], () => setOpen(true))
command && run(command)}
>
{t('nothingMatched')}
{(command) => (
{t(command.label)}
)}
```
`onValueChange` reports `null` as well as a value — a palette that is cleared
says so — which is why the example checks before running anything.
### More than one kind of thing
A palette that lists works, versions and notes together is a `List` over the
*groups*, with a `Collection` inside each — not a `List` inside a `List`, since
the list is the listbox and there is one per palette:
```tsx
{(group: Group) => (
{t(`kind.${group.kind}`)}
{(hit: Hit) => (
{hit.title}
)}
)}
```
The arrow keys walk the flattened rows, so reaching the end of one group steps
into the next rather than stopping at a caption.
Mapping a group's rows by hand works too, but then the palette has to be told
how to match an item back to a value — and for rows fetched fresh from a
server, identity comparison never does, so it needs an `isItemEqualToValue`
that `Collection` makes unnecessary.
### Searching somewhere else
The filtering is client-side over `items`. A palette whose search happens on a
server should pass `filter={null}`: without it the already-filtered hits are
filtered a second time against the same query, by title alone, which throws
away every hit that matched on something the client cannot see.
### Palette or SearchField
**CommandPalette** when it is the same box for the whole application, reached
by a shortcut from anywhere, and choosing a row *does* something.
**[SearchField](/dowel/components/search-field/)** when the query filters
something already on screen, in place, and there is nothing to choose.
**[Combobox](/dowel/components/combobox/)** when the typing ends in a value
going into a form field.
## Props
### `CommandPalette` (root)
Base UI's Combobox root, unchanged.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `items` | `unknown[]` | | What gets filtered. Anything, not only strings |
| `open` / `onOpenChange` | | | Controlled — the shortcut lives elsewhere |
| `onValueChange` | `(value, details) => void` | | Run what was chosen. Reports `null` on a clear |
| `filter` | `function \| null` | | Replaces the comparison; `null` for a list filtered on a server |
### `CommandPalettePopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `md \| lg` | `md` | How tall the list may grow before it scrolls |
| `container` | `Element \| Ref` | document body | Where to portal to |
| `aria-label` | `string` | **required** | What the palette is called — see above |
| `className` | `string` | | Merged so the caller wins a conflict |
### `CommandPaletteInput`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `hint` | `string[]` | | Drawn at the right of the field, as `['Escape']`. Decorative and `aria-hidden` |
| `className` | `string` | | Merged so the caller wins a conflict |
Everything else reaches the `
`, so `placeholder` and `aria-label` work
as usual.
### `CommandPaletteRow`
The row's own layout, for use inside a `CommandPaletteItem`.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `icon` | `ReactNode` | | Drawn first, at its natural size |
| `hint` | `ReactNode` | | Drawn last and quiet — where the command lives, or its shortcut |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
`CommandPaletteItem`, `CommandPaletteList`, `CommandPaletteEmpty` and
`CommandPaletteGroup` pass their props to Base UI unchanged. `Empty` stays
mounted so its announcement fires, so it must not be hidden or rendered
conditionally.
`commandPalettePopupVariants` is exported for the same clothes elsewhere, and
`commandPaletteItemVariants` is Combobox's row — a palette is a list of
choices, and two lists of choices in one product should not differ.
---
# ConfirmDialog
Source: https://lacodda.github.io/dowel/components/confirm-dialog
FENCE0
The component lands in `components/ui/confirm-dialog.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#confirm-dialog
The three sizes, with a destructive action - in either theme, and in the accent of any product of the line.
## When this and not a Dialog
Almost everything about the two is the same, and that is the point: the
difference is not clothes, it is what the popup is allowed to do.
Reach for **Dialog** when the content is a place the user went — a form, a
detail view, a picker. Wandering out of it by clicking away is the right
behaviour, because nothing is lost.
Reach for **ConfirmDialog** when the popup is a question about something
irreversible: deleting, discarding, revoking, overwriting. A stray click beside
it must not count as an answer.
The test is not how important the content feels. It is whether dismissing it by
accident would be a loss.
## Usage
```tsx
}>
Delete project
Delete the project?
Everything in it goes too. This cannot be undone.
}>Cancel
Delete
```
The parts are exposed rather than wrapped in one component with `title` and
`footer` props — a dialog that owns its own close button owns a word for it,
and that is a word the product cannot translate.
## Props
### `ConfirmDialog` — the root
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `open` | `boolean` | | Controlled, with `onOpenChange` |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(open, details) => void` | | |
`modal` and `disablePointerDismissal` are not accepted here. Base UI forces
both on for an alert dialog, which is the whole difference from Dialog.
### `ConfirmDialogPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | A confirm dialog is a question, so the sizes run one step narrower than Dialog's |
| `className` | `string` | | Merged so the caller wins a conflict |
It renders its own portal and its own backdrop, so there is nothing to arrange
around it.
### The rest
| Part | | |
| --- | --- | --- |
| `ConfirmDialogTrigger` | | What opens it. `render` to use your own button |
| `ConfirmDialogTitle` | | The question. The popup's `aria-labelledby` points at it |
| `ConfirmDialogDescription` | | What the answer costs. The popup's `aria-describedby` |
| `ConfirmDialogActions` | | Right-aligned row for the two answers |
| `ConfirmDialogClose` | | Closes it. `render` to use your own button |
## Notes
**It announces itself as `alertdialog`.** That is the semantic half of the
difference, and the only one a screen reader can hear: it tells the reader the
popup is interrupting rather than presenting, and that the description should
be read out without being asked for.
**A press outside does nothing.** Not a preference, not a prop — Base UI's
`AlertDialog.Root` omits `disablePointerDismissal` from Dialog's props and
forces it true.
**`Escape` still closes it.** This surprises people who expect "not
dismissible" to mean both, and it is the right call: a popup with no keyboard
way out is a trap. The distinction that survives is between a deliberate
keypress and an absentminded click.
**So give it a close.** Since only something inside can dismiss it by pointer,
a `ConfirmDialogPopup` with no `ConfirmDialogClose` in it is a dead end for
anyone on a touch screen.
**No colour of its own.** Every class is written in tokens, so the same list is
correct in both themes and in every product's accent — no `dark:` utilities
anywhere in it.
---
# ContextMenu
Source: https://lacodda.github.io/dowel/components/context-menu
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#context-menu
Right click inside the panel to open it.
## Notes
A list of actions opened by a right click, or by a long press on a touch
screen, over an *area* rather than from a button. The trigger is not a control
- it is the region the menu belongs to: a row, a canvas, a file tile - so it
renders a `
` and is announced as nothing at all.
That is the only difference from [Menu](/dowel/components/menu/). Everything
below the root is Menu's own - Base UI re-exports the portal, the positioner,
the popup and the items from the menu package - so the popup that opens here
is the same popup, with the same keyboard, the same type-ahead and the same
submenus. The clothes are imported from Menu rather than copied, which is
declared in the component's dependency budget: two class lists that started
identical do not stay that way.
**Use it when** the actions belong to a thing on the screen and there is no
room for a button beside it - a row in a long table, an item on a canvas.
**Use Menu instead** when there is a button, because a right click is
undiscoverable: nobody finds a context menu they were not expecting. A context
menu should repeat actions that are reachable some other way, not hide them.
It positions against the point that was clicked, so there is no `side` or
`align` to give it, and no anchor to attach it to from elsewhere. Base UI also
withholds `openOnHover`, `modal` and `handle` here for the same reason.
```tsx
import {
ContextMenu,
ContextMenuItem,
ContextMenuPopup,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
}>{row}
{t('rename')}
{t('duplicate')}
{t('delete')}
```
## Props
### `ContextMenuPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | How wide the popup starts |
| `container` | `Element \| Ref` | document body | Where to portal to |
| `className` | `string` | | Merged so the caller wins a conflict |
### `ContextMenuItem`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `tone` | `default \| danger` | `default` | `danger` draws the destructive one apart |
| `disabled` | `boolean` | `false` | Skipped by the keyboard, not only dimmed |
| `closeOnClick` | `boolean` | `true` | For the item that should leave the menu open |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
`ContextMenu` (root), `ContextMenuTrigger` (the area), `ContextMenuGroup`,
`ContextMenuGroupLabel`, `ContextMenuSeparator`, `ContextMenuSub`,
`ContextMenuSubTrigger`, `ContextMenuCheckboxItem` and
`ContextMenuCheckboxIndicator` pass their props to Base UI unchanged.
---
# Copyable
Source: https://lacodda.github.io/dowel/components/copyable
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#copyable
An id, a hash, a path - copied with one click.
## Notes
**The rule comes from nitid:** if a value is worth showing, it is worth being
able to copy. Selecting a monospaced id by hand is a small daily tax.
**It is a `
`,** so the keyboard reaches it and a screen reader says
what it does.
**The confirmation is announced, not only drawn.** A tick that appears silently
tells a sighted user it worked and tells nobody else.
**A refusal is reported.** The clipboard needs a secure context and sometimes a
permission; a button that looks like it worked and did not is worse than one
that admits it failed.
```tsx
ok || toast(t('copy.failed'))}
>
{commit.short}
```
**Both labels are required, on purpose.** They are what a screen reader
announces, and a default would be an English word this component invented —
one the product's translations never reach. Requiring them means a product
that forgets fails to compile rather than shipping English to a reader who
does not read it.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `children` | `string` | | The visible text |
| `value` | `string` | the text | What lands on the clipboard, if different |
| `label` | `string` | **required** | What the button is called |
| `copiedLabel` | `string` | **required** | What is announced after a copy |
| `onCopy` | `(ok: boolean) => void` | | Told what happened |
---
# DatePicker
Source: https://lacodda.github.io/dowel/components/date-picker
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#date-picker
Empty, chosen, bounded, and inside a Field.
## Notes
**The trigger is a button, not a typable field.** A date input has to answer
"what does `03/04/26` mean" in a locale it cannot be sure of, and it answers
wrong for half the world. A button showing the date spelled out has no such
question. Where typing genuinely matters — a birth date forty years back — a
calendar is the wrong control anyway.
```tsx
```
**What is shown is `Intl`'s own long form** — "2 September 2026" here,
"September 2, 2026" in the United States. The stored value never changes.
**It closes when a day is chosen.** Choosing is the whole errand; waiting for
a second dismissing click is one click too many.
**`name` puts the value where a form can find it.** A button is not a field,
so without it a form submits the screen and loses the date — the component
writes a hidden input instead.
```tsx
// submits 2026-09-02
```
The value, the arithmetic and the keyboard are the
[Calendar](/dowel/components/calendar/)'s; this is the field around it.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `IsoDate` | | `YYYY-MM-DD` |
| `onValueChange` | `(value) => void` | | |
| `min`, `max` | `IsoDate` | | Bounds, inclusive |
| `placeholder` | `string` | | Required — what the trigger says when empty |
| `previousMonthLabel` | `string` | | Required |
| `nextMonthLabel` | `string` | | Required |
| `locale` | `string` | reader's own | |
| `name` | `string` | | Puts the value in the form |
| `disabled` | `boolean` | `false` | |
---
# DateRangePicker
Source: https://lacodda.github.io/dowel/components/date-range-picker
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#date-range-picker
Empty, half made, whole, and bounded.
## Notes
**The state between the two clicks is the component.** After the first click
there is a start and no end — not an incomplete value to hide, and not a range
of one day. It is the normal middle of the interaction, and the reader has to
see it: the first day marked, the popup still open, the trigger already saying
something.
```tsx
```
`onValueChange` firing on the first click is what lets a product show "from 2
September" while the reader is still choosing the other end. A picker that
reports nothing until the range is whole appears to do nothing at all.
**The clicks can come in either order.** Clicking the 20th and then the 10th
means the 10th to the 20th, because that is plainly what was meant. Refusing it
would be correct and unhelpful.
**Clicking again starts over.** Once a range is whole, the next click is a new
start rather than an edit of the old end — which is what a reader means by
clicking a third time.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `{ start?, end? }` | | Either end may be absent |
| `onValueChange` | `(value) => void` | | Fires on both clicks |
| `min`, `max` | `IsoDate` | | Bounds, inclusive |
| `placeholder` | `string` | | Required |
| `previousMonthLabel` | `string` | | Required |
| `nextMonthLabel` | `string` | | Required |
| `locale` | `string` | reader's own | |
| `disabled` | `boolean` | `false` | |
---
# Dialog
Source: https://lacodda.github.io/dowel/components/dialog
FENCE0
The component lands in `components/ui/dialog.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#dialog
Every size, with a title, a description and a row of actions - in either theme, and in the accent of any product of the line.
## When this and not an Alert, a Toast or a Drawer
Reach for **Dialog** when the reader has to *answer* something before the
thing they started can continue: delete this or not, which profile to switch
to, what to call the version being saved. It takes the whole screen out of
reach, which is only fair when nothing else can proceed.
Reach for **[Alert](/dowel/components/alert/)** when there is nothing to
answer — a condition that is still true, sitting beside what it is about.
Reach for **[Toast](/dowel/components/toast/)** when something already
happened and needs no decision at all.
Reach for **[Drawer](/dowel/components/drawer/)** when the panel is a place to
work rather than a question: a chat, a form with its own life, anything the
reader returns to. A drawer can be left open; a dialog is answered and gone.
## Usage
```tsx
{t('deleteDraft')}
{t('deleteDraftHint')}
{t('cancel')} } />
{t('delete')}
```
## Notes
**The parts are exposed rather than wrapped.** A single component taking
`title` and `footer` props is a slot with extra steps, and a dialog that owns
its own close button owns a word for it — a word the product cannot translate.
**The behaviour is Base UI's**: the focus trap, returning focus to whatever
opened it, `Escape`, the scroll lock, and the `aria-labelledby` tying the
popup to its own title. What is ours is the clothes and the motion.
**It never grows taller than the window.** A dialog with more in it than the
window is tall would otherwise centre itself and hang off both ends — the
title out of reach above the viewport, the buttons below it. The popup is
capped at the viewport height and scrolls inside itself, and that scroll does
not reach the page behind. Found on a release editor in a consuming product,
which is the shape that does it: half a dozen fields and a row of actions.
**Give it a title.** `DialogTitle` is what names the dialog to a screen
reader; without one the popup is announced as an unlabelled region. If the
design has no visible heading, the title is still the right element to render
visually hidden.
## Props
`DialogPopup`:
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm` \| `md` \| `lg` | `md` | Width; the height is capped at the viewport in every one |
| `backdrop` | `boolean` | `true` | The popup draws the scrim itself. Turn it off only where the dialog is shown alongside other things on purpose - a gallery, a screenshot |
| `container` | `Element` | `document.body` | Where to portal to |
| `className` | `string` | | Merged so the caller wins a conflict |
`Dialog`, `DialogTrigger`, `DialogClose`, `DialogTitle`, `DialogDescription`
and `DialogActions` take the props their Base UI parts take; `render` composes
each with your own element. `DialogBackdrop` is exported for the rare case of
drawing the scrim yourself, and is not needed otherwise.
---
# Drawer
Source: https://lacodda.github.io/dowel/components/drawer
FENCE0
The component lands in `components/ui/drawer.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#drawer
The three sides - right, left and a bottom sheet - in either theme, and in the accent of any product of the line.
## When this and not a Dialog
Both are modal, and both hold the screen while they are open. The difference is
shape, and shape follows content.
Reach for **Dialog** when the content is short and self-contained: a question,
a small form, a confirmation with two buttons. Centred, and gone in a moment.
Reach for **Drawer** when the content is tall or long-lived — a filter sheet
with a dozen controls, a detail pane you read alongside the list, a form that
would need scrolling in a centred box. It pins itself to an edge and keeps its
full height, so scrolling happens inside it rather than moving the whole panel.
On a small screen a bottom-sheet drawer is usually the right answer where a
desktop layout would use a dialog: it is reachable by thumb and it can be
swiped away.
## Usage
```tsx
}>Filters
Filters
Narrow the list down.
{/* the controls */}
}>Cancel
Apply
```
`DrawerPopup` renders its own portal, backdrop and viewport, so there is
nothing to arrange around it.
### Match the swipe to the side
`side` lives on the popup and `swipeDirection` on the root, so the two are set
together by hand — there is no way for the component to infer one from the
other:
| `side` | `swipeDirection` |
| --- | --- |
| `right` | `"right"` |
| `left` | `"left"` |
| `bottom` | `"down"` (Base UI's default) |
Left unmatched, the drawer slides in from one edge and is flicked away towards
another.
## Props
### `Drawer` — the root
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `open` | `boolean` | | Controlled, with `onOpenChange` |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(open, details) => void` | | |
| `swipeDirection` | `up \| down \| left \| right` | `down` | Which way a finger dismisses it — match it to `side` |
| `modal` | `boolean \| 'trap-focus'` | `true` | |
| `snapPoints` | `DrawerSnapPoint[]` | | Partial heights for a bottom sheet |
### `DrawerPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `side` | `right \| left \| bottom` | `right` | The edge it comes from, and the axis it slides along |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
| Part | | |
| --- | --- | --- |
| `DrawerTrigger` | | What opens it. `render` to use your own button |
| `DrawerTitle` | | The popup's `aria-labelledby` points at it |
| `DrawerDescription` | | The popup's `aria-describedby` |
| `DrawerActions` | | Pushed to the bottom of the panel, right-aligned |
| `DrawerClose` | | Closes it. `render` to use your own button |
## Notes
**Base UI positions none of it.** Unlike Popover there is no positioner and no
anchor to measure against — the edge is entirely CSS, which is what the `side`
variant is. It drives three things that have to agree: where the viewport
pushes the panel, which border it grows against, and which way it is translated
while opening and closing.
**The transitions key off `data-starting-style` and `data-ending-style`**
rather than `data-closed`, which is Base UI's own convention for the drawer.
The reason is that a drawer is dragged as well as animated: the popup carries a
live `--drawer-swipe-movement-*` while a finger is on it, and the transform has
to compose with that rather than replace it.
**The page behind it is genuinely out of reach.** This is the half that gets
forgotten, because a drawer covers only one edge and the rest of the page looks
usable. It is not — Base UI marks it inert, so Tab cannot walk off into a page
the user cannot see they are editing.
**`DrawerActions` sits at the bottom.** `mt-auto`, so the buttons stay at the
foot of a tall panel rather than wandering up it when there is little content.
**No colour of its own.** Every class is written in tokens, so the same list is
correct in both themes and in every product's accent — no `dark:` utilities
anywhere in it.
---
# DurationField
Source: https://lacodda.github.io/dowel/components/duration-field
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#duration-field
Filled, empty, and inside a Field with a hint.
## Notes
**The alternative is two number boxes.** Labelled "hours" and "minutes", they
mean two tab stops, two validations, and a reader who has to divide 90 minutes
in their head before typing. Here they write it the way they say it.
```tsx
```
**The value is minutes** — a plain number, not a string and not a Duration
object. A field whose value has to be parsed by its caller has moved the
problem rather than solved it.
**Loose going in, strict coming out.** Everything below means ninety minutes,
and all of them are written back as `1h 30m`:
| Typed | Means |
| --- | --- |
| `1h 30m`, `1h30m` | the canonical spelling, spaced or not |
| `90`, `90m` | a bare number is minutes |
| `1.5h`, `1,5h` | a decimal, with either separator |
| `1:30` | the clock spelling |
That asymmetry is the design: being strict on input means rejecting people,
being loose on output means the column of values never lines up.
**What it will not do is guess.** `1h banana` is refused rather than read as an
hour — a typo that parses is a value nobody questions afterwards. When what was
typed cannot be read, the box is put back to the value the form actually holds
rather than left saying something untrue.
**Empty is `null`.** No estimate is not an estimate of nothing.
**It does not reformat under the cursor.** Typing `1h 3` leaves `1h 3` alone
until you leave the field or press Enter; a field that reformats on every
keystroke fights the person using it.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `number \| null` | | Minutes; `null` is empty |
| `onValueChange` | `(value) => void` | | Fires on blur and Enter, not per key |
| `placeholder` | `string` | | A duration in the canonical spelling reads best |
| `disabled`, `readOnly`, `required` | `boolean` | `false` | |
`parseDuration` and `formatDuration` are exported beside the component, for a
product that has to read or write the same spellings elsewhere.
---
# Field
Source: https://lacodda.github.io/dowel/components/field
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#field
A field with a hint, a field with an error, and a field whose label is only for screen readers.
## Notes
**The wiring is the whole component.** Every form is the same four parts
repeated — a name, the control, sometimes a hint, sometimes an error — and
written by hand each time they drift apart: the label loses its `htmlFor`, the
hint becomes a `
` nothing announces, the error turns red and is read out
by nothing at all. None of that shows in a screenshot.
```tsx
```
**The control is handed over, not just nested.** `Field` passes its id and
`aria-*` attributes to the element you give it, which is why `children` is a
single element rather than arbitrary nodes. A plain child would render a label
whose `for` points at an id nothing carries — it looks wired and names nothing.
**`error` is a string, not a rule.** dowel has no opinion about where it came
from, because a design system that picked a form library would be choosing for
products that already chose. Its presence is what marks the control invalid, so
there is no separate `invalid` prop to keep in step.
```tsx
// Base UI's own validation
// a schema, react-hook-form, or a server that just said no
```
**The error takes the hint's place rather than joining it.** Two lines of small
print under one control is one too many, and the error is the one that matters.
**The label is always rendered.** A field named only by its placeholder loses
that name the moment someone types, and a placeholder was never a label to
anything reading the page aloud. `labelHidden` takes it off the screen with
`sr-only` and leaves it in the accessibility tree.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `label` | `ReactNode` | | Required. Always rendered |
| `children` | `ReactElement` | | The control. One element |
| `help` | `ReactNode` | | A hint, hidden while an error shows |
| `error` | `ReactNode` | | Its presence marks the control invalid |
| `labelHidden` | `boolean` | `false` | Keep the label for readers, not the screen |
| `required` | `boolean` | `false` | Adds the mark a reader looks for |
| `name` | `string` | | So a `Form` can attach a server error by name |
| `disabled` | `boolean` | `false` | |
---
# FileDrop
Source: https://lacodda.github.io/dowel/components/file-drop
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#file-drop
Taking files, filtering them, and closed.
## Notes
**It takes files. It does not upload them.** Where they go, with which
credentials, retried how, resumed or not — that is your transport, and a
primitive that owned it would be wrong for every product whose upload does not
look like the one it guessed. This is the same boundary
[Field](/dowel/components/field/) draws around validation: the component knows
the shape of the interaction, you know what the interaction means.
```tsx
upload(files)}
onReject={(rejections) => toast(explain(rejections))}
aria-label={t('attachments')}
>
{t('drop-or-choose')}
```
**There is a real ` ` underneath**, hidden with `sr-only`
rather than `display: none`. That is not fussiness: hidden the other way it is
unfocusable, the label stops reaching it, and the field becomes mouse-only. The
native input is also what the operating system's picker attaches to and what a
screen reader announces as a file field.
**Rejected files are reported, not swallowed.** A file dropped and silently
ignored looks like a broken page. `onReject` hands back each file with a
reason — `type`, `size` or `count` — and you turn that into a sentence, in your
own language.
**Two browser defaults are handled**, and both are invisible until they are
not. `dragover` is prevented, without which the browser navigates to the
dropped file and the form the reader was filling in is simply gone. And the
input's value is cleared after each change, without which choosing the same
file twice in a row fires nothing the second time.
**The lit state counts enters and leaves rather than toggling.** `dragleave`
fires when the pointer crosses onto a *child* of the zone, so a zone that
toggled on it flickers as the pointer moves over its own text — the commonest
defect in hand-written drop zones.
---
# Input
Source: https://lacodda.github.io/dowel/components/input
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#input
A field in every state, in either theme.
## Notes
**It is a plain ` `.** Autofill, spellcheck, `type="email"` validation
and the right keyboard on a phone all still work, because none of them were
replaced with something that looks similar.
**Invalid is driven by `aria-invalid`**, not by a prop of its own. The
attribute is what a screen reader reads, so making it the source of the colour
keeps the two from disagreeing:
```tsx
```
**The focus ring is drawn outside the border**, not instead of it. A field that
only changes colour on focus is invisible to a reader who does not separate
those two colours.
## Props
Everything an ` ` takes, plus `className`. `ref` reaches the element,
for focusing it or reading its selection.
---
# Kbd
Source: https://lacodda.github.io/dowel/components/kbd
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#kbd
Single keys and whole shortcuts, written the way this platform writes them.
## Notes
**It is a `` element**, so a screen reader announces it as keyboard input
rather than reading a stray capital letter.
**The platform substitution is the useful part.** A shortcut written `Ctrl+K`
is simply wrong on a Mac, where it is `⌘K` — and every product either
hard-codes one of them or writes the branch again.
```tsx
```
`Mod` is command on Apple platforms and control everywhere else. `Alt` and
`Shift` substitute the same way; `Enter`, `Escape` and the arrows are written
as symbols on every platform.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `keys` | `string[]` | | A shortcut, in order. Without it, the children are the key |
---
# Menu
Source: https://lacodda.github.io/dowel/components/menu
FENCE0
The component lands in `components/ui/menu.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#menu
Items, a destructive item, a checkbox item, a group with its label, a separator and a submenu - in either theme, and in the accent of any product of the line.
## When this and not a Select, a Combobox or a Dialog
Reach for **Menu** when the entries are *actions*: the row menu, the overflow
menu, the one behind the three dots. Choosing one does something and the menu
closes.
Reach for **[Select](/dowel/components/select/)** when the entries are
*values* and one of them stays chosen afterwards. A menu forgets; a select
remembers, and shows what it remembers on the trigger.
Reach for **[Combobox](/dowel/components/combobox/)** when there are enough
values that finding one by typing is faster than reading the list.
Reach for **[Dialog](/dowel/components/dialog/)** when the action needs more
from the reader than picking it — a name to type, a choice to confirm.
## Usage
```tsx
} />
{t('rename')}
{t('keepThisDate')}
{t('delete')}
```
## Notes
**What makes a menu hard is the keyboard**, and that is the part worth not
writing again: arrows that wrap, Home and End, type-ahead that finds an item
by its first letters, a submenu that opens on the right key and closes when
the pointer leaves diagonally. Base UI has all of it. The click-outside
listener and the Escape handler every product wrote by hand come with it.
**The items are exposed rather than taken as an array.** A list of
`{ label, onSelect }` is enough until the first separator, the first checkbox
item and the first submenu — and each of those arrives as another field on the
object rather than as the JSX it obviously is.
**A trigger that is only an icon needs a name.** `MenuTrigger` renders your
own button; if that button has no text, give it an `aria-label`, or the menu
is announced as an unlabelled control.
**`tone="danger"` is for the item that destroys something**, not for emphasis.
One per menu at most: a list where several entries are red says nothing about
which of them is the dangerous one.
## Props
`MenuPopup`:
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm` \| `md` | `md` | The minimum width of the panel |
| `side` | `top` \| `right` \| `bottom` \| `left` | | Preferred side; Base UI flips it when it does not fit |
| `align` | `start` \| `center` \| `end` | | Alignment along that side |
| `sideOffset` | `number` | `4` | Distance from the trigger, in pixels |
| `container` | `Element` | `document.body` | Where to portal to |
| `className` | `string` | | Merged so the caller wins a conflict |
`MenuItem`, `MenuSubTrigger` and `MenuCheckboxItem`:
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `tone` | `default` \| `danger` | `default` | |
| `className` | `string` | | Merged so the caller wins a conflict |
`Menu`, `MenuTrigger`, `MenuGroup`, `MenuGroupLabel`, `MenuSeparator`,
`MenuSub` and `MenuCheckboxIndicator` take the props their Base UI parts take;
`render` composes each with your own element.
---
# NumberField
Source: https://lacodda.github.io/dowel/components/number-field
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#number-field
With a stepper and without, with a unit, and formatted as a currency.
## Notes
**Not ` `.** The native one draws its own spinner where no
stylesheet reaches, rejects a pasted `1 234,50`, and on some browsers silently
blanks itself on anything it dislikes. This is a text box that tells the
keyboard it is numeric and names itself as a number field, which is what a
screen reader announces.
**Empty is `null`, not zero.** "No number" and "the number zero" are different
answers — no price yet and free — and a field that returns 0 for an empty box
makes them the same the moment it saves.
```tsx
```
**The unit is a caption, not part of the value.** Inside the input it is
something to parse and something to delete by accident; beside it, it cannot
be typed into and the value stays a number.
```tsx
```
**How it reads is `Intl`, not a hand-rolled separator.** A field showing
`1234.5` to someone who writes `1 234,5` is one they translate in their head.
```tsx
```
`locale` is left alone by default, which means the reader's own — state one
only when the figure belongs to a place rather than to a person.
**The stepper's buttons are `aria-hidden`.** The field already announces its
value and its range; two more unlabelled controls tell a reader nothing it did
not have. Use `hideStepper` where the range is wide enough that the buttons are
an invitation to click sixty times.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `number \| null` | | `null` is an empty box |
| `defaultValue` | `number` | | Uncontrolled |
| `onValueChange` | `(value) => void` | | Receives `null` when emptied |
| `min`, `max` | `number` | | |
| `step` | `number` | `1` | What the arrows change it by |
| `largeStep` | `number` | | What PageUp and PageDown change it by |
| `format` | `Intl.NumberFormatOptions` | | Currency, percent, precision |
| `locale` | `Intl.LocalesArgument` | reader's own | |
| `unit` | `ReactNode` | | A caption beside the field |
| `hideStepper` | `boolean` | `false` | |
---
# Panel
Source: https://lacodda.github.io/dowel/components/panel
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#panel
The three surfaces, and the caption that usually sits above one.
## Notes
**Three variants, for three distances from the page.** `raised` sits on it,
`floating` has left it — a menu, a popover — and says so with a shadow, and
`inset` is for something inside another panel, where a second border would be
a box drawn in a box.
**The corner is `lg`.** The two products this came from disagreed about it —
16px in one, 12px in the other — for a component with the same name and the
same job in both.
**`SectionLabel`** is included because a panel almost always has one and every
product wrote its own. Its letter-spacing is the one they argued about: 0.08em
in six files and 0.09em in three, for the same visual element. It is a token
now, so the argument cannot recur.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `variant` | `raised \| floating \| inset` | `raised` | How far from the page |
---
# PasswordField
Source: https://lacodda.github.io/dowel/components/password-field
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#password-field
Masked, revealed, and inside a Field with an error.
## Notes
**The reveal is the component, and it is not a convenience.** A masked field is
the only one in a form where a typo cannot be seen, so people either paste or
type slowly and get it wrong anyway. The toggle is what turns an unverifiable
field into a checkable one, and it is why long passphrases became usable.
What it costs is a moment where the password is on the screen, so the component
states its two rules rather than leaving them to each product:
- **it always starts masked**, and there is no prop to start it revealed;
- **revealing is the reader's own action** — never a default, and never
something a form can turn on for them.
```tsx
```
**The two labels are required.** The button's name is what a screen reader
announces, and it changes with the state — it describes the action, not the
condition. A default here would be English shipped inside a primitive.
**`autoComplete` is not defaulted either.** `current-password` on a login,
`new-password` on a sign-up; getting it wrong is how a password manager fills
the wrong box, and only the product knows which form this is.
**The button is `type="button"`.** One that defaults to submit sends the form
on the first click, with the password half typed.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | | Controlled |
| `defaultValue` | `string` | | Uncontrolled |
| `onValueChange` | `(value) => void` | | |
| `showLabel` | `string` | | Required. The button while masked |
| `hideLabel` | `string` | | Required. The button while showing |
| `autoComplete` | `string` | | `current-password` or `new-password` |
| `disabled`, `readOnly`, `required` | `boolean` | `false` | |
---
# Popover
Source: https://lacodda.github.io/dowel/components/popover
FENCE0
The component lands in `components/ui/popover.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#popover
The sizes and the four sides, with and without the arrow - in either theme, and in the accent of any product of the line.
## When this and not a Dialog
Both hold interactive content, so the choice is about where the content
belongs and what it interrupts.
Reach for **Popover** when the content is *about* the control: a filter panel
on a filter button, a colour picker on a swatch, a short form on an edit
button. It appears beside its trigger, the page underneath stays live, and
focus is not trapped.
Reach for **Dialog** when the content is the screen's whole business for the
moment, or when it is long enough that anchoring it beside a button is absurd.
Reach for **Tooltip** if the content is a *label* — a few words, nothing to
click. A popover holds things you interact with; a tooltip holds a phrase.
## Usage
```tsx
}>Filters
Filters
Narrow the list down.
}>Done
```
`PopoverPopup` renders its own portal, positioner and arrow, so there is
nothing to arrange around it.
## Positioning
`side` and `align` are a preference, not an instruction. Base UI measures the
trigger and the panel and flips or shifts the popup when the preferred side
does not fit, so a popover near the bottom of the window comes out above its
trigger. That is the behaviour worth having — a popover that stays where it was
told is a popover half off the screen.
The arrow follows: it is rotated to whichever side the popup actually landed
on, not the side that was asked for.
## Props
### `Popover` — the root
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `open` | `boolean` | | Controlled, with `onOpenChange` |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(open, details) => void` | | |
| `modal` | `boolean \| 'trap-focus'` | `false` | Leave it off unless the panel really is a decision |
### `PopoverPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | |
| `side` | `top \| right \| bottom \| left \| inline-start \| inline-end` | `bottom` | Preferred side; Base UI flips it when it does not fit |
| `align` | `start \| center \| end` | `center` | Alignment along that side |
| `sideOffset` | `number` | `8` | Distance from the trigger, in pixels |
| `arrow` | `boolean` | `true` | Draw the notch pointing back at the trigger |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
| Part | | |
| --- | --- | --- |
| `PopoverTrigger` | | What opens it, and what the panel is measured against |
| `PopoverTitle` | | The popup's `aria-labelledby` points at it |
| `PopoverDescription` | | The popup's `aria-describedby` |
| `PopoverClose` | | Closes it. `render` to use your own button |
| `PopoverArrow` | | Exported for a popup assembled by hand |
## Notes
**It is not modal.** What is under the panel stays in the accessibility tree
and stays clickable. That is right for something beside a control and wrong for
something the page has to wait on — for that, use Dialog or ConfirmDialog.
**The trigger says whether it is open.** `aria-expanded` is the only way a
screen reader learns that this button revealed something rather than did
something, and Base UI puts it there.
**It is portalled.** An anchored popup rendered in place is clipped by the
first ancestor with `overflow: hidden`, which is where most of them go to die.
**Give it a title.** The popup's accessible name comes from `PopoverTitle`;
without one a screen reader announces "dialog" and nothing else.
**No colour of its own.** Every class is written in tokens, so the same list is
correct in both themes and in every product's accent — no `dark:` utilities
anywhere in it.
---
# PreviewCard
Source: https://lacodda.github.io/dowel/components/preview-card
FENCE0
The component lands in `components/ui/preview-card.tsx` and is yours to edit.
See it live on the stand: https://lacodda.github.io/dowel/stand/#preview-card
A link, its card, and the three sizes - in either theme, and in the accent of any product of the line.
## When this and not a Tooltip
A **tooltip** holds a phrase and nothing can be reached inside it. A
**preview card** holds rich content — an avatar, a couple of lines, a figure —
and it *is* hoverable: the pointer travels from the link into the card without
it disappearing, so a link inside it can actually be clicked.
That is the trick the component exists for. A card that vanished when the
pointer left the link could not be read, let alone clicked into.
## When this and not a Popover
A **popover** is opened deliberately, from a button, and is reachable by
everyone. A **preview card** opens on hover over a link, and is not.
## It is an enhancement, not a delivery mechanism
Base UI treats this the way it treats Tooltip: a visual aid for sighted mouse
and keyboard users. It is not reachable on a touch screen and not announced by
a screen reader.
**So nothing in the card may be the only place it appears.** Everything shown
has to also be on the page the link points at. The card saves a click for
people who can see it; it is never how the information gets delivered.
The other half of that promise is the trigger. Render it as the anchor itself,
so it stays a real link — it navigates, it opens in a new tab, and a screen
reader announces it as one.
If the content has to be reachable by everyone, use a Popover opened from a
real button instead.
## Usage
```tsx
} delay={300}>
Ada Lovelace
Wrote the first algorithm intended for a machine.
Read the notes
```
`PreviewCardPopup` renders its own portal, positioner and arrow.
Note that `delay` sits on the **trigger** here, not on the root — unlike
Tooltip, where it is a root prop.
## Props
### `PreviewCard` — the root
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `open` | `boolean` | | Controlled, with `onOpenChange` |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(open, details) => void` | | |
### `PreviewCardTrigger`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `delay` | `number` | `600` | How long to wait on hover, in milliseconds |
| `render` | `ReactElement` | | Render the anchor itself, so it stays a real link |
### `PreviewCardPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | |
| `side` | `top \| right \| bottom \| left \| inline-start \| inline-end` | `bottom` | Preferred side; Base UI flips it when it does not fit |
| `align` | `start \| center \| end` | `center` | |
| `sideOffset` | `number` | `8` | Distance from the link, in pixels |
| `arrow` | `boolean` | `true` | |
| `className` | `string` | | Merged so the caller wins a conflict |
`PreviewCardArrow` is exported too, for a popup assembled by hand.
## Notes
**It opens on focus as well as on hover**, so someone tabbing through a
paragraph of links gets the same previews.
**`Escape` closes it** without leaving the link.
**It is portalled.** A card rendered in place is clipped by the paragraph the
link sits in.
**No colour of its own.** Every class is written in tokens, so the same list is
correct in both themes and in every product's accent — no `dark:` utilities
anywhere in it.
---
# RadioGroup
Source: https://lacodda.github.io/dowel/components/radio-group
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#radio-group
A column of options, a row of them, and a disabled group.
## Notes
**Reach for this when the options are worth the space.** A handful of short
choices read faster laid out than hidden behind a trigger, and each one becomes
a target rather than a step. Past about five, or when the labels are long, a
[Select](/dowel/components/select/) is the honest choice — this is not a Select
with more pixels.
```tsx
Green
Ripe
Soft
```
**The group is the control.** That is what the arrow keys move within, what a
screen reader announces as one thing with a position in it, and why the group
takes the value rather than each button. `Radio` outside a group is a checkbox
that cannot be unchecked, which is why it is only useful inside one.
**One tab stop, not three.** Tab reaches the group and the arrows move the
choice — the behaviour a native radio group has and a row of styled buttons
does not.
**`orientation="horizontal"` for two or three short options.** A column is the
default because it stays readable as labels grow.
Name the group. It is the question the options answer, and without it a reader
hears three values and no subject:
```tsx
{/* or */}
... {/* named by the field */}
```
## Props
### RadioGroup
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | | Controlled |
| `defaultValue` | `string` | | Uncontrolled |
| `onValueChange` | `(value) => void` | | |
| `orientation` | `vertical` \| `horizontal` | `vertical` | |
| `disabled`, `readOnly`, `required` | `boolean` | `false` | |
| `name` | `string` | | For a form |
### Radio
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | | Required |
| `children` | `ReactNode` | | The words next to the dot |
| `disabled` | `boolean` | `false` | |
---
# RatingScale
Source: https://lacodda.github.io/dowel/components/rating-scale
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#rating-scale
Scored, not judged yet, a longer scale, and disabled.
## Notes
**Marks rather than stars.** Stars carry a meaning of their own — a review, a
public verdict — and this is as often "how hard was this" or "how finished is
it" as it is "how good".
**Not judged yet is a state, not a zero.** This is the whole reason the
component exists and the reason it is not a
[Slider](/dowel/components/slider/) with a small range or a
[RadioGroup](/dowel/components/radio-group/) with five options. "I have not
scored this" and "I scored it nothing" are different facts, and a control that
collapses them makes the difference unrecoverable the moment it saves.
```tsx
```
There are two ways back to it, and both exist because losing either one loses
the state:
- **the pointer** — click the mark already chosen;
- **the keyboard** — Backspace or Delete.
**`emptyLabel` is required.** It is what a screen reader hears in place of a
number, and a default here would be English shipped inside a primitive. Same
for `label`: a bare row of marks names nothing.
**One control, not five.** The container is the slider — one tab stop, arrows
within it — and the marks are plain elements. A `` per mark would be a
nested interactive control inside a `slider`, which assistive technology is not
promised to announce or reach; the donor this came from had them as
`aria-hidden` buttons, which hides them from a reader without making them stop
being controls.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `scale` | `number` | | How many marks |
| `value` | `number \| undefined` | | `undefined` is not judged yet |
| `onValueChange` | `(value) => void` | | Receives `undefined` when cleared |
| `label` | `string` | | Required. What is being judged |
| `emptyLabel` | `string` | | Required. Heard in place of a number |
| `disabled` | `boolean` | `false` | |
---
# SaveState
Source: https://lacodda.github.io/dowel/components/save-state
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#save-state
Beside a field, the three states, and running.
## Notes
**A form without a Save button still has to say what it did.** Otherwise the
reader is left guessing whether their edit survived, and the usual answer to
that guess is pressing Ctrl+S at a page that has no such thing.
```tsx
const status = useSaveStatus(mutation.isPending, mutation.isError)
```
**The tick decays.** One that never leaves stops meaning "just now" and becomes
furniture — part of the layout the reader stops seeing, which is the opposite
of what an indicator is for. `linger` controls how long it stays.
**Only a real save earns one.** The hook watches the falling edge of the
mutation, so mounting beside one that is already idle shows nothing. Without
that, arriving at a page would flash a tick for a save that happened before the
reader got there.
**A failure is not a save.** Pass `isError` and the tick is skipped: the
failure is already being announced by a toast or an error on the field, and
saying "saved" underneath that is worse than saying nothing at all.
**It holds its width when idle.** The line sits next to a field; if it grew and
shrank with its own text, the layout would twitch on every save. It goes
transparent rather than away.
**One live region, announced politely.** The ring is drawn from
[Spinner](/dowel/components/spinner/)'s variants rather than by using Spinner,
which carries its own `role="status"` — nesting live regions gives a screen
reader two announcements for one event. And `polite` rather than `assertive`,
because interrupting someone mid-sentence to say a field saved is how people
learn to turn a screen reader's verbosity down.
**The words are yours.** A primitive with a string of its own cannot be
translated.
---
# SearchField
Source: https://lacodda.github.io/dowel/components/search-field
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#search-field
Empty with its shortcut showing, and with a query and the clear button in its place.
## Notes
An Input with three small things the products kept not doing, so they are done
once here rather than four times badly.
**A magnifier**, so the field is recognisable before it is read.
**A way to clear it that is not "select all and delete"** — and one a keyboard
can reach. This is the half that goes wrong: the cross is usually a decorative
`` with an `onClick`, which Tab skips and a screen reader does not
announce, so the only way to empty the field is the one the mouse knows. Here
it is a real `` with a name, and clearing puts the cursor back in the
field, because clearing and then having to click the field again is half a
feature.
**The shortcut that focuses it**, shown in the field rather than learned. The
same array both draws the hint and binds the key, so what is shown and what
works cannot drift apart.
### `type="search"` is deliberate
It is what tells a browser to offer previous queries, and what makes Escape
clear the field on the platforms where that is the convention. The browser's
own clear button is hidden, because it is drawn in the operating system's
chrome and cannot be made to match — the same reason the line does not use a
native ``.
### The two props that are switches
`clearLabel` is the clear button. Leave it out and no button is drawn, which
is the right shape for a field that filters as you type and is emptied by
other means. There is no separate `showClear` flag, because a flag can
disagree with the label, and a button announced as nothing is worse than no
button at all.
`shortcut` is the same idea: give it one and it is both shown and bound; leave
it out and neither happens.
The hint and the clear button share the right edge, so the hint gives way as
soon as there is something to clear.
### The shortcut does not fire while someone is typing
It is [`useShortcut`](/dowel/components/shortcut/) underneath, with its default
intact: `Mod+K` typed into another field belongs to that field. It focuses
*and selects*, so the shortcut replaces a stale query rather than appending to
it.
```tsx
import { SearchField } from '@/components/ui/search-field'
```
### SearchField or Combobox
**SearchField** when what is typed filters something the reader is already
looking at — a table, a list of files, a page of results. There is nothing to
choose from a popup; the query is the whole interaction.
**[Combobox](/dowel/components/combobox/)** when the typing ends in a choice:
the reader is picking one of a set, and the query is only how they find it.
**[CommandPalette](/dowel/components/command-palette/)** when it is the same
box for the whole application, opened by a shortcut from anywhere.
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | | Required and controlled — a search box that owns its own text cannot be cleared by the thing that owns the results |
| `onValueChange` | `(value: string) => void` | | Told the new query on every keystroke |
| `clearLabel` | `string` | | What the clear button is called. No default; without it no button is drawn |
| `shortcut` | `string[]` | | As `['Mod', 'K']`. Shown at the right of the field, and bound |
| `ref` | `Ref` | | Reaches the input, not the wrapper |
| `className` | `string` | | Merged onto the wrapper so the caller wins a conflict |
Everything else is passed to the ` `, so `placeholder`, `aria-label`,
`disabled`, `name` and the rest work as usual. `type` is not among them: it is
always `search`.
---
# Select
Source: https://lacodda.github.io/dowel/components/select
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#select
Closed, open, grouped, and with more than one chosen.
## Notes
The component the oldest rule in the line is about. A native `` cannot
be dressed: the browser draws its popup itself, in the operating system's
chrome, with the operating system's fonts and spacing, and no CSS reaches
inside it. On a screen where every other control is the product's own, the one
native dropdown reads as a foreign object - and on Windows it reads as a
foreign object from 1998.
So this renders `` and a portalled list of
`role="option"`, with zero native elements. The test asserts exactly that,
because it is the whole reason the component exists, and the ESLint rule
`dowel/no-native-select` stops the native one coming back by hand.
What that costs is everything the browser was doing for free: the keyboard,
type-ahead, the announcement of the selected value, the scroll into view, and
on a phone the whole native picker. Base UI reimplements all of it, which is
the only reason the trade is worth making - a hand-rolled dropdown is how a
product ships a control a screen reader cannot see.
The trigger wears Input's field classes, imported rather than copied, because
a select and a text field sit next to each other in every form there has ever
been.
### Two traps worth knowing
`SelectValue`'s `children` is a **function of the value**, not a node. Passing
a node pins the trigger to that node forever and the selection never appears;
the placeholder goes in `placeholder`.
And what it shows is the **raw value** - `plum`, not `Plum` - unless the root
is given an `items` map to look the label up in. A product that skips `items`
gets its own identifiers on screen, and it is not obvious why.
### Select or Combobox
**Select** when the options are few enough to read: a status, a currency, a
priority. The reader scans a list and picks.
**[Combobox](/dowel/components/combobox/)** when they are not. A Select stops
being usable somewhere around thirty options, and a country picker, a tag
field or a person picker is well past that - the reader knows what they want
and needs to type it rather than hunt for it. Combobox is also the one with
chips, so it is the answer for multi-select that has to *show* what is chosen.
Both support `multiple`, and both refuse to be a native ``.
```tsx
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
{t('low')}
{t('high')}
```
## Props
### `Select` (root)
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `multiple` | `boolean` | `false` | Turns `value` into an array |
| `items` | `Record \| Array` | | What the trigger looks labels up in |
| `value` / `onValueChange` | | | Controlled; `defaultValue` for uncontrolled |
| `disabled` | `boolean` | `false` | |
### `SelectTrigger`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | Matches Input's heights |
| `className` | `string` | | Merged so the caller wins a conflict |
### `SelectPopup`
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `sm \| md \| lg` | `md` | A floor; it matches the trigger's width |
| `side` | `top \| right \| bottom \| left` | `bottom` | A preference - Base UI flips it when it does not fit |
| `align` | `start \| center \| end` | | Alignment along that side |
| `sideOffset` | `number` | `4` | Distance from the trigger |
| `container` | `Element \| Ref` | document body | Where to portal to |
| `className` | `string` | | Merged so the caller wins a conflict |
### The rest
`SelectValue`, `SelectIcon`, `SelectItem`, `SelectItemText`,
`SelectItemIndicator`, `SelectGroup`, `SelectGroupLabel` and `SelectSeparator`
pass their props to Base UI unchanged. The `cva` variants
(`selectTriggerVariants`, `selectPopupVariants`, `selectItemVariants`) are
exported for a product that needs the same clothes somewhere else.
---
# Shortcut
Source: https://lacodda.github.io/dowel/components/shortcut
FENCE0
See it live on the stand: https://lacodda.github.io/dowel/stand/#shortcut
A bound shortcut, and a field it deliberately does not fire into.
## Notes
Not a component — a hook and two predicates. There is nothing to draw, which
is why the page is mostly about a default.
Binding a key is four lines everyone can write. The two decisions inside those
four lines are what this exists for.
### A shortcut must not fire while someone is typing
This is the default, and it is the point. A keystroke aimed at an ` `, a
`