# dowel 0.20.3 The complete documentation of the lacodda line design system, generated from the same sources the site is built from. --- # dowel Source: https://lacodda.github.io/dowel/ One theme, one token vocabulary, one set of primitives - so every product in the line looks made by one hand, and nobody sees the joint. ## Two places **[The stand](/dowel/stand/)** is where the components are. Every one of them, live, in either theme and in the accent of any product of the line — change the accent and watch what follows from it: the hover shade, the soft fill, the focus ring, the colour of text on an accent fill. None of which a component states for itself. **These pages** are where the reasons are. What a token means, why a scale has the steps it has, what a component will and will not do for you. ## Status **v0.20.3.** The theme and its scales, an accent for each of the fourteen products of the line, and forty-five primitives — each with a page of its own on [the stand](/dowel/stand/), which remembers the theme and the accent you left it in. Components install from the registry with `npx shadcn add`, singly or as a [set](/dowel/guides/registry/); the theme is the `dowel-ui` package, which also carries the [migration tools](/dowel/guides/migration/) a project crossing from stock shadcn runs on itself. Two products of the line run on it: [kilna](https://github.com/lacodda/kilna) and [kasl-server](https://github.com/lacodda/kasl-server). Development goes in versions, each one a single coherent theme; the [roadmap](https://github.com/lacodda/dowel#roadmap) says what is next. --- # Getting Started Source: https://lacodda.github.io/dowel/getting-started dowel ships in two parts: the **theme** as an npm package, and **primitives** as a shadcn-compatible registry. ## Install the theme ```console $ pnpm add dowel-ui ``` :::note The package is `dowel-ui`, not `dowel`: npm declines the bare name as too close to `del` and `bower`. The design system is dowel everywhere else — the repository, these docs, the mark. ::: ```css /* src/styles.css */ @import 'tailwindcss'; @import 'dowel-ui/theme.css'; ``` That is a working theme: the vocabulary of the line, in dark and light, with dowel's own amber as the accent. ## Make it the product's A product states one colour — its own, from the line's registry of marks — and the theme derives the rest: ```css @import 'dowel-ui/theme.css'; :root { --accent-base: #d9569e; } ``` This moves the accent and its hover partner, the accent's soft fill, the focus ring, and the tint the greys carry. It also settles what colour text has to be on top of an accent fill, which is the part products usually get wrong: a light accent takes dark glyphs, a dark one takes white, and `--on-accent` works that out rather than asking. If the chrome should stay neutral instead of leaning towards the product's hue, point the neutrals somewhere else: ```css :root { --accent-base: #d9569e; --neutral-base: #8e8e93; } ``` ## Themes Dark is the default. Without a class on the root element the reader's operating system decides; a class pins it. ```html ``` A component never uses a `dark:` utility. Every colour goes through a token, and the theme swaps the token underneath — which is what lets a product be checked against a mockup in the mockup's own words. ## Use the tokens With Tailwind 4 they are utilities, because the theme declares them in a `@theme` block: ```html
``` Outside Tailwind they are ordinary custom properties: ```css .thing { background: var(--raise); border: 1px solid var(--line); color: var(--text); } ``` The stock Tailwind palette is dropped on purpose, so `bg-zinc-800` does not compile. See the [token reference](/dowel/reference/tokens/) for the whole vocabulary, shown in both themes. ## Primitives Primitives are copied into your project rather than imported: ```console $ npx shadcn add https://lacodda.github.io/dowel/r/button.json ``` The component lands in `components/ui/` and is yours to edit. Or take the set a product usually starts from — the everyday controls, the overlays it needs on day one, and the ways of choosing something — in one command: ```console $ npx shadcn add https://lacodda.github.io/dowel/r/app.json ``` There are three such sets, and each minor of the registry is also served frozen at a path that never changes, for an install that has to be repeatable. See [installing from the registry](/dowel/guides/registry/). --- # Anti-patterns Source: https://lacodda.github.io/dowel/concepts/anti-patterns Every one of these has been made — in a product of the line, or in dowel itself. They are listed because a rule with no failure attached reads as a preference, and preferences get overruled at four in the afternoon. ## Writing a colour down ```tsx // ✗
``` **What happens:** the element keeps that colour in the other theme, and in every other product. It is also invisible until someone opens the light theme, which is usually a screenshot from a user. **Instead:** name a token. `bg-accent`, `border-line`, `text-dim`. The [`dowel/no-raw-color`](/dowel/guides/linting/) rule reports the raw value. **The exception, and it is a real one:** a translucent black or white is not a colour, it is a veil — `bg-black/50` over a photograph, `text-white/70` on a cover image. Those are legitimate and the rule allows them. This was found by running the rule over a real product before publishing it: twenty-six hits, and eleven of them were this. ## Deciding which theme you are in ```tsx // ✗
``` **What happens:** two sets of colours to keep in step, and a component that cannot be checked against a mockup, because the mockup speaks in one theme and the component speaks in two. **Instead:** one token. `bg-raise` *is* the raised surface, and the theme decides what that means. A `dark:` utility in a dowel component means a token is missing — that is the bug, not the utility. ## The native ` ``` **What happens:** the browser draws that popup itself, in the operating system's chrome, at the operating system's font size. No stylesheet reaches inside it. On a screen where every other control is the product's own, it reads as a foreign object — and on Windows it is a different foreign object than on macOS. **Instead:** [`Select`](/dowel/components/select/), which renders `} /> ``` **What happens:** the first screen whose dialog needs two buttons and a description, or a title with an icon in it, cannot use the component. It gets copied and edited, and now there are two dialogs. **Instead:** expose the parts. `DialogPopup`, `DialogTitle`, `DialogDescription`, `DialogActions`. More lines at the call site, and every screen that differs can still use the component rather than fork it. ## A word inside a primitive ```tsx // ✗ } /> {t('cancel')}} /> {t('delete')}} /> ``` **The buttons stay yours.** `ActionBarButton` takes your element through `render` and composes with it, so a `Button` keeps its variants and gains the toolbar's keyboard handling. This draws the strip; it does not decide what is in it. **`position` is why it exists.** A long form whose Save button is a thousand pixels below the field being edited has a Save button the reader has to go looking for. `bottom` and `top` stick; `static` is a strip in the flow of the page. **The seam faces the content.** Stuck at the bottom the rule is on top of the bar, at the top the other way about — a bar that drew both would read as a box. A static bar draws neither, because there is nothing to separate it from. **Name it when a page has more than one.** "Formatting" and "Bulk actions" are different toolbars, and a screen reader announcing "toolbar" twice tells the reader nothing about which one they are in. **Stacking comes from the theme.** A stuck bar uses `--z-sticky` rather than a number invented here, so it agrees with every overlay in the set instead of fighting one. --- # Alert Source: https://lacodda.github.io/dowel/components/alert FENCE0 The component lands in `components/ui/alert.tsx` and is yours to edit. See it live on the stand: https://lacodda.github.io/dowel/stand/#alert Every tone, with and without an icon, a heading and an action - in either theme, and in the accent of any product of the line. ## When this and not a Toast, a Banner or a Dialog The three messages look similar and mean different things, so the choice is about *what the message is*, not how much room it needs. Reach for **Alert** when the message is a condition that is *still true* — and will still be true after a reload — about the thing it sits beside: this field could not be saved, this profile has no axes yet, this export is out of date. It sits in the flow of the page, next to what it is about. Reach for **[Toast](/dowel/components/toast/)** when something already happened, needs no decision, and stops being interesting immediately. **If dismissing it would lose information, it is not a toast** — that is an alert. Reach for **[Banner](/dowel/components/banner/)** when the condition is about the whole application rather than one part of it, and is true on every screen. An alert belongs to what it sits beside; a banner belongs to the whole screen. Reach for **[Dialog](/dowel/components/dialog/)** when the reader has to *answer* something. ## Usage ```tsx } title={t('notSaved')}> {t('connectionDropped')} ``` ```tsx {t('addOne')}} > {t('axesExplained')} ``` Everything but the body is optional, and nothing is drawn for a slot that was not given — a one-line alert comes out one line tall. The icon is the product's own, on purpose. An icon that means "warning" here should be the one it means everywhere else in the product, and a component library that ships its own set makes that impossible. **Colour is emphasis, never the message.** An alert that means "failed" says so in words as well, so a reader who does not separate red from green gets the whole of it. ## `role` is yours to give There is no default, deliberately. An alert that appears *because of something the reader just did* should be `role="alert"`, so it is announced. One that is simply part of the page — already there when the screen loaded — should have no role at all, or a screen reader interrupts whatever it was saying to read the furniture. Only the product knows which it has. ```tsx {/* Already on the screen. Read in its turn. */} {t('exportIsStale')} {/* Appeared just now, because saving failed. Announce it. */} {t('couldNotSave')} ``` ## Props | Prop | Type | Default | | | --- | --- | --- | --- | | `tone` | `neutral \| good \| warn \| bad \| info` | `neutral` | | | `icon` | `ReactNode` | | Drawn before the text; the product's own | | `title` | `ReactNode` | | The heading. Optional — a one-line alert needs none | | `action` | `ReactNode` | | Drawn at the end: a link, a fix, a dismiss the product owns | | `role` | `string` | | No default. `alert` when it appeared just now | | `className` | `string` | | Merged so the caller wins a conflict | `children` is the body. Anything else goes to the `
`. --- # Badge Source: https://lacodda.github.io/dowel/components/badge FENCE0 See it live on the stand: https://lacodda.github.io/dowel/stand/#badge Every variant, including the four status colours. ## Notes **A badge is not a button.** It is state attached to something — a count, a status, a label. If it can be clicked, it is a [Chip](/dowel/components/chip/). **Colour is emphasis, never the message.** A badge that means "failed" says so in words as well. Colour alone is invisible to a reader who does not separate red from green, and to anyone printing the screen. ```tsx Failed ``` ## Props | Prop | Type | Default | | | --- | --- | --- | --- | | `variant` | `outline \| soft \| accent \| good \| warn \| bad \| info` | `outline` | | --- # Banner Source: https://lacodda.github.io/dowel/components/banner FENCE0 The component lands in `components/ui/banner.tsx` and is yours to edit. See it live on the stand: https://lacodda.github.io/dowel/stand/#banner Every tone, with and without an icon and an action, loose and pinned - in either theme, and in the accent of any product of the line. ## When this and not an Alert, a Toast or a Dialog The three messages look similar and mean different things, so the choice is about *what the message is*, not how much room it needs. Reach for **Banner** when the condition is about the whole application and is true no matter which screen you are on: you are offline, this build is a preview, your licence expires on Friday, a new version is ready to install. It goes across the top, above the application's own chrome. Reach for **[Alert](/dowel/components/alert/)** when the condition is about one part of the page and belongs beside it. An alert speaks about what it sits next to; a banner speaks about everything. Reach for **[Toast](/dowel/components/toast/)** when something already happened, needs no decision, and goes away. **If dismissing it would lose information, it is not a toast.** Reach for **[Dialog](/dowel/components/dialog/)** when the reader has to *answer* something. ## Usage ```tsx }> {t('youAreOffline')} ``` ```tsx } action={} > {t('newVersionReady')} ``` Nothing is drawn for a slot that was not given, so a banner with no action is one line with no gap held open at the end. **Colour is emphasis, never the message.** The sentence says what the tone suggests, so a reader who does not separate red from green gets the whole of it. ## Dismissal is the product's decision The banner does not dismiss itself, and there is no `onClose`. Whether "you are offline" *can* be dismissed is a judgement about the product, not about the strip of colour: some conditions the reader is allowed to put away and some they are not. So the close button is passed in like any other action, and the product decides whether there is one. ```tsx } > {t('licenceExpiresFriday')} ``` ## `sticky`, and what it needs `sticky` pins the banner to the top of the viewport, above the application's own chrome, for the conditions that must not scroll away — offline, expired. It is `position: sticky`, so it needs what that needs: an ancestor that actually scrolls, and no `overflow: hidden` between the banner and it. A sticky banner inside a clipped container simply scrolls away, silently. ## `role="status"`, and when to change it The default is `role="status"`, not `role="alert"`. A banner is usually already on the screen when it loads, and a live region set to `alert` fires on load and interrupts whatever a screen reader was saying about the page. `status` is announced when it changes and stays quiet when it does not, which is what a banner that is simply *there* should do. Pass `role="alert"` for the other case — a banner that appeared just now because the connection dropped. ## Props | Prop | Type | Default | | | --- | --- | --- | --- | | `tone` | `neutral \| accent \| good \| warn \| bad \| info` | `neutral` | | | `sticky` | `boolean` | `false` | Pinned to the top of the viewport | | `icon` | `ReactNode` | | Drawn first; the product's own | | `action` | `ReactNode` | | Drawn at the end: a fix, a link, a dismiss the product decides is allowed | | `role` | `string` | `status` | `alert` when it appeared just now | | `className` | `string` | | Merged so the caller wins a conflict | `children` is the message. Anything else goes to the `
`. --- # Button Source: https://lacodda.github.io/dowel/components/button FENCE0 The component lands in `components/ui/button.tsx` and is yours to edit. See it live on the stand: https://lacodda.github.io/dowel/stand/#button Every variant, size and state - in either theme, and in the accent of any product of the line. ## Variants `primary` is the one action a screen is about — one per screen, or it is not primary. `ghost` is the default and the quiet one. `soft` is for something already chosen. `danger` is destructive, and stays quiet until hovered, because a red button is not a warning if everything is red. ## Sizes Two text sizes and two icon sizes. An icon button is square by construction rather than by a padding that happens to match. ## States Disabled keeps the button's own colour and loses contact instead, so it reads the same whatever the product's accent is. A link rendered with `render` is still a link: it navigates, it opens in a new tab, and a screen reader announces it as one. A ` ``` ## Props | Prop | Type | Default | | | --- | --- | --- | --- | | `variant` | `primary \| ghost \| soft \| danger \| icon` | `ghost` | What the button is for | | `size` | `sm \| md \| icon-sm \| icon-md` | `md` | | | `render` | `ReactElement \| (props) => ReactElement` | | Render something else with the button's clothes on | | `className` | `string` | | Merged so the caller wins a conflict | Everything else goes to the ` ``` 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 `} /> ``` ## 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 */} ``` `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. ``` `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 `