src/components/ui/ is the Kit you keep. Everything beside it under src/components/ is the storefront you override. daisyUI stays the source of truth for colour, size, and the base look of every element below; the Kit holds the decisions a theme cannot carry.
| Component | What it decides | Props beyond the native element |
|---|---|---|
Button |
Hover group, loading state, type="button" default, link or button |
loading, to, hash |
Card |
Border, surface, body padding, heading size | title |
Input |
Width, invalid styling, wiring to its Field |
none |
Field |
Label, hint, error, and the id wiring between them | label, hint, error, id |
Select |
A themed listbox on a button, the highlight, what Enter and Escape do, and wiring to its Field |
options, value, onValueChange, placeholder, and the copy |
MultiSelect |
The same, holding a list of values, and a box on every row that fills when it is chosen | options, values, onValuesChange, placeholder, and the copy |
SearchSelect |
A filtered listbox, the highlight, and what Enter and Escape do | options, value, onValueChange, filter, loading, and the copy |
MultiSearchSelect |
The same, held as chips, and what Backspace removes | options, values, onValuesChange, removeLabel, and the copy |
Modal |
A native dialog, its accessible name, and one close event for every way out | open, onClose, title, closeLabel, boxClassName |
Drawer |
The same dialog against one edge, at full height, and the width of the sheet | side, plus everything Modal takes |
Tooltip |
A tip a screen reader can read, and Escape to dismiss it | tip, and a function as its child |
Toast |
The queue, the portal, the live regions, and when a message goes away | Not an element: useToast() returns toast |
Every one of them except Toast extends its native element's props, forwards its ref, and merges className last. A class you pass wins over anything in the table above. Toast is a provider and a hook rather than an element, and takes its className in the options you raise a message with.
daisyUI draws the focus ring. The Kit does not repeat it, so a second outline cannot appear; Button is still where you would change it.
The Kit carries no copy. No file under src/components/ui/ imports t(), so every visible string arrives as a prop. Translation stays where the string is written.
Button
The Kit button. It owns the focus ring, the loading state, the hover group, and the branch between a link and a button; daisyUI still owns the colour and the size, passed through className.
src/lib/docs/showcase/kit-button.tsx
import { Button } from '@/components/ui/Button/Button'
export function KitButtonDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button className="btn-primary" onClick={() => {}}>
Save changes
</Button>
<Button className="btn-primary" loading onClick={() => {}}>
Saving
</Button>
<Button className="btn-ghost btn-sm" disabled onClick={() => {}}>
Unavailable
</Button>
<Button to="/about" className="btn-secondary">
A link, same button
</Button>
</div>
)
}to renders a <Link>; anything else renders a <button>. Passing both is a type error. For a link that needs typed search or params, use <Link> with a className instead: forwarding those through a wrapper costs the router's inference from to.
loading prepends a spinner, sets aria-busy, and blocks activation on a <button>; a link takes no loading state. The label stays rendered, so the button keeps its accessible name and does not resize under the pointer.
Card
Entitlement
The card surface, its body padding, and the size of that heading, all decided once.
No title, and a border colour the caller chose.
The bordered surface eight components had each spelled out for themselves, with the padding and heading size they had already drifted apart on.
src/lib/docs/showcase/kit-card.tsx
import { Card } from '@/components/ui/Card/Card'
export function KitCardDemo() {
return (
<div className="flex flex-col gap-4">
<Card title="Entitlement">
<p className="text-base-content/70 text-sm">
The card surface, its body padding, and the size of that heading, all
decided once.
</p>
</Card>
<Card className="border-primary">
<p className="text-sm">No title, and a border colour the caller chose.</p>
</Card>
</div>
)
}title renders an <h2>, which is the right level under a page's <h1>. A card that needs another level omits title and writes its own heading as the first child.
Input
The Kit input, on its own with an aria-label. Inside a Field it takes its id and description from that Field instead.
src/lib/docs/showcase/kit-input.tsx
import { Input } from '@/components/ui/Input/Input'
export function KitInputDemo() {
return (
<div className="flex max-w-sm flex-col gap-3">
<Input
aria-label="Search"
type="search"
placeholder="Clerk id, or part of an email"
/>
</div>
)
}Inside a Field, the input takes its id, its aria-describedby, and its invalid state from that field. Outside one, it is the daisyUI input at full width and you supply its name through aria-label, as the admin search box does.
An explicit prop always wins over the surrounding Field.
input-bordered and select-bordered do not exist in daisyUI 5. .input and .select draw their own borders. If you find those classes in your own code, they are styling nothing.
Field
How this account shows in your post list.
How this account shows in your post list.
You already have a channel with that name.
Label, control, hint, and error, with the id wiring that connects them. The second field is in error, so aria-describedby points at the message rather than at the hint.
src/lib/docs/showcase/kit-field.tsx
import { Field } from '@/components/ui/Field/Field'
import { Input } from '@/components/ui/Input/Input'
export function KitFieldDemo() {
return (
<div className="flex max-w-sm flex-col gap-5">
<Field label="Channel name" hint="How this account shows in your post list.">
<Input name="channelName" defaultValue="My YouTube" autoComplete="off" />
</Field>
<Field
label="Channel name"
hint="How this account shows in your post list."
error="You already have a channel with that name."
>
<Input name="channelName" defaultValue="My YouTube" autoComplete="off" />
</Field>
</div>
)
}label is required, because a control with no name is the defect this component exists to prevent.
A field describes itself with one element at a time. With no error, aria-describedby points at the hint. Once error is set, it points at the error instead, so a screen reader does not read the hint again after the message that replaced it.
Field owns the markup and not the state. Keep value and onChange in your form, as the five shipped forms do. The reasoning is in docs/adr/0034-component-kit-extends-daisyui.md.
Label and ErrorText are exported from the same file for a control Field does not wrap.
Select
Arrow keys to move, Enter or Space to choose, Escape to close.
That plan is no longer sold.
A button and a themed listbox over one value, wired to its Field exactly as Input is. Arrow keys move the highlight, Enter or Space chooses, and focus never leaves the button.
src/lib/docs/showcase/kit-select.tsx
import { useState } from 'react'
import { Field } from '@/components/ui/Field/Field'
import { Select } from '@/components/ui/Select/Select'
const ENVIRONMENTS = [
{ value: 'sandbox', label: 'Sandbox' },
{ value: 'staging', label: 'Staging' },
{ value: 'live', label: 'Live' },
] as const
const PLANS = [
{ value: 'pro', label: 'Pro' },
{ value: 'retired', label: 'Retired offer' },
] as const
type Environment = (typeof ENVIRONMENTS)[number]['value']
type Plan = (typeof PLANS)[number]['value']
export function KitSelectDemo() {
const [environment, setEnvironment] = useState<Environment | null>('sandbox')
const [plan, setPlan] = useState<Plan | null>('retired')
return (
<div className="flex max-w-sm flex-col gap-5">
<Field
label="Environment"
hint="Arrow keys to move, Enter or Space to choose, Escape to close."
>
<Select
options={ENVIRONMENTS}
value={environment}
onValueChange={setEnvironment}
placeholder="Choose an environment"
statusText={(count) => `${count} environments`}
/>
</Field>
<Field
label="Plan"
error={plan === 'retired' ? 'That plan is no longer sold.' : undefined}
>
<Select
options={PLANS}
value={plan}
onValueChange={setPlan}
placeholder="Choose a plan"
statusText={(count) => `${count} plans`}
/>
</Field>
</div>
)
}A button with role="combobox" over the role="listbox" SearchSelect opens, with no text field in front of it: the APG combobox pattern in its select-only variant. It replaces a native <select>, whose open list is operating-system chrome the theme cannot touch. Inside a Field it takes its id, its description, and its invalid state from that field, exactly as Input does. LanguageSelect in the header is the shipped call site.
Focus stays on the button for the whole interaction, and the highlighted row is named through aria-activedescendant exactly as it is below. The chosen row carries a check mark.
| Key | What happens |
|---|---|
| Arrow down, arrow up | Moves the highlight, wrapping at either end, and opens a closed list |
| Enter, space | Chooses the highlighted row, or opens a closed list |
| Escape | Closes the list, leaving the value where it was |
| Home, end | Jumps the highlight to the first or last row |
There is no clear control, so onValueChange never hands back null: a reader who has chosen once cannot get back to nothing chosen. placeholder is what the button shows until then.
Pass icon for a trigger with no room for text: the button shows the icon instead of the label and drops its caret, and keeps the label visually hidden so the combobox still announces its value. The header's language picker is the case that uses it.
An option carries an optional lang, for a list whose labels are not in the language of the page. The language picker is the case that needs it, and without it a screen reader reads "Dansk" in an English voice.
The value is controlled, and the button submits nothing on its own. Keep the value in your form state, as with every other Kit control.
MultiSelect
Enter or Space toggles a row, and the list stays open.
Chosen: name, plan
The same button holding more than one value. Every row carries a box that fills when it is chosen, and the list stays open as you pick.
src/lib/docs/showcase/kit-multi-select.tsx
import { useState } from 'react'
import { Field } from '@/components/ui/Field/Field'
import { MultiSelect } from '@/components/ui/MultiSelect/MultiSelect'
const COLUMNS = [
{ value: 'name', label: 'Name' },
{ value: 'email', label: 'Email' },
{ value: 'plan', label: 'Plan' },
{ value: 'status', label: 'Status' },
{ value: 'joined', label: 'Joined' },
{ value: 'last-seen', label: 'Last seen' },
] as const
type Column = (typeof COLUMNS)[number]['value']
export function KitMultiSelectDemo() {
const [columns, setColumns] = useState<readonly Column[]>(['name', 'plan'])
return (
<div className="flex max-w-sm flex-col gap-4">
<Field
label="Visible columns"
hint="Enter or Space toggles a row, and the list stays open."
>
<MultiSelect
options={COLUMNS}
values={columns}
onValuesChange={setColumns}
placeholder="Choose columns"
statusText={(count) => `${count} columns`}
/>
</Field>
<p className="text-base-content/60 text-sm">
Chosen: {columns.length === 0 ? 'nothing yet' : columns.join(', ')}
</p>
</div>
)
}Select over a listbox that is aria-multiselectable, holding a list of values instead of one. Enter or space toggles the highlighted row and the list stays open, so the second value costs one more key press rather than another click. Every row carries a box that fills when the row is chosen, so what is chosen reads at a glance rather than from a change of weight.
The button names the chosen labels in the order they were added, separated by commas and cut off with an ellipsis when they run out of room. It shows no chips, because a chip's remove control inside a button would be one interactive element nested in another. Reach for MultiSearchSelect when the reader needs chips or a filter.
A value with no matching option is still named on the button, by the value itself.
SearchSelect
Type to filter, arrow keys to move, Enter to choose, Escape to close.
Chosen: DK
The combobox daisyUI has no class for, written here against the APG pattern. Type to filter, arrow keys to move the highlight, Enter to choose. This one is live, and focus never leaves the text field.
src/lib/docs/showcase/kit-search-select.tsx
import { useState } from 'react'
import { Field } from '@/components/ui/Field/Field'
import { SearchSelect } from '@/components/ui/SearchSelect/SearchSelect'
const COUNTRIES = [
{ value: 'au', label: 'Australia' },
{ value: 'at', label: 'Austria' },
{ value: 'be', label: 'Belgium' },
{ value: 'br', label: 'Brazil' },
{ value: 'ca', label: 'Canada' },
{ value: 'dk', label: 'Denmark' },
{ value: 'fi', label: 'Finland' },
{ value: 'fr', label: 'France' },
{ value: 'de', label: 'Germany' },
{ value: 'ie', label: 'Ireland' },
{ value: 'it', label: 'Italy' },
{ value: 'jp', label: 'Japan' },
{ value: 'nl', label: 'Netherlands' },
{ value: 'nz', label: 'New Zealand' },
{ value: 'no', label: 'Norway' },
{ value: 'pl', label: 'Poland' },
{ value: 'pt', label: 'Portugal' },
{ value: 'es', label: 'Spain' },
{ value: 'se', label: 'Sweden' },
{ value: 'ch', label: 'Switzerland' },
{ value: 'gb', label: 'United Kingdom' },
{ value: 'us', label: 'United States' },
] as const
type Country = (typeof COUNTRIES)[number]['value']
export function KitSearchSelectDemo() {
const [country, setCountry] = useState<Country | null>('dk')
return (
<div className="flex max-w-sm flex-col gap-4">
<Field
label="Billing country"
hint="Type to filter, arrow keys to move, Enter to choose, Escape to close."
>
<SearchSelect
options={COUNTRIES}
value={country}
onValueChange={setCountry}
placeholder="Search countries"
emptyText="No country matches that."
loadingText="Loading countries"
clearLabel="Clear the chosen country"
statusText={(count) =>
count === 1 ? '1 country matches' : `${count} countries match`
}
/>
</Field>
<p className="text-base-content/60 text-sm">
Chosen: {country === null ? 'nothing yet' : country.toUpperCase()}
</p>
</div>
)
}A text input with role="combobox" over a role="listbox" it filters. daisyUI has no class for this, and no headless library backs it, so the behaviour is this repository's own, written against the APG combobox pattern. docs/adr/0034-component-kit-extends-daisyui.md records why it is owned rather than installed.
Focus stays on the text input for the whole interaction. The rows are not focusable; the highlighted one is named to a screen reader through aria-activedescendant, which is what lets you keep typing while the list moves under you.
| Key | What happens |
|---|---|
| Any character | Filters the list and opens it |
| Arrow down, arrow up | Moves the highlight, wrapping at either end, and opens a closed list |
| Enter | Chooses the highlighted row |
| Escape | Closes the list and restores the chosen label |
| Home, end | Moves the caret in the text, as in any input |
Selection is manual. Moving the highlight changes nothing until you press Enter or click a row, so an arrow key followed by a tab leaves the value where it was.
Opening the list empties the text, so the whole list is there to arrow through. Closing it puts the chosen label back, which is what Escape and clicking away both do.
options is the whole list and filter decides which rows survive a query. The default is a case-insensitive substring of label, with an empty query matching everything. Pass your own to match a code or an alias as well:
filter={(option, query) =>
option.label.toLowerCase().includes(query.trim().toLowerCase()) ||
option.value.startsWith(query.trim())
}
emptyText, loadingText, clearLabel, and statusText are the copy, and all four are required. statusText receives the number of rows and its result is announced politely whenever the list changes, which is how a screen reader learns that a keystroke narrowed twelve rows to one. The plural is yours, because the Kit carries no copy and Danish counts differently.
loading shows loadingText in place of the rows, for a list you fetch.
The value is controlled and null means nothing is chosen. A clear button appears once something is, and it is the only thing that emits null.
The list is positioned absolutely against the control, so an ancestor with overflow: hidden clips it. Inside a Modal, the dialog's own box is the ancestor to watch.
MultiSearchSelect
Backspace on an empty field removes the last chip.
Chosen: purchase.refunded, publication.failed
The same combobox holding more than one value, each as a chip with its own remove control. The list stays open as you pick, and Backspace on an empty field removes the last chip.
src/lib/docs/showcase/kit-multi-search-select.tsx
import { useState } from 'react'
import { Field } from '@/components/ui/Field/Field'
import { MultiSearchSelect } from '@/components/ui/MultiSearchSelect/MultiSearchSelect'
const EVENTS = [
{ value: 'purchase.completed', label: 'Purchase completed' },
{ value: 'purchase.refunded', label: 'Purchase refunded' },
{ value: 'subscription.started', label: 'Subscription started' },
{ value: 'subscription.renewed', label: 'Subscription renewed' },
{ value: 'subscription.past_due', label: 'Subscription past due' },
{ value: 'subscription.cancelled', label: 'Subscription cancelled' },
{ value: 'entitlement.granted', label: 'Entitlement granted' },
{ value: 'entitlement.revoked', label: 'Entitlement revoked' },
{ value: 'publication.scheduled', label: 'Publication scheduled' },
{ value: 'publication.published', label: 'Publication published' },
{ value: 'publication.failed', label: 'Publication failed' },
{ value: 'channel.connected', label: 'Channel connected' },
{ value: 'user.signed_up', label: 'User signed up' },
{ value: 'user.promoted', label: 'User promoted to admin' },
] as const
type EventName = (typeof EVENTS)[number]['value']
export function KitMultiSearchSelectDemo() {
const [events, setEvents] = useState<readonly EventName[]>([
'purchase.refunded',
'publication.failed',
])
return (
<div className="flex max-w-md flex-col gap-4">
<Field
label="Email me about"
hint="Backspace on an empty field removes the last chip."
>
<MultiSearchSelect
options={EVENTS}
values={events}
onValuesChange={setEvents}
placeholder="Search events"
emptyText="No event matches that."
loadingText="Loading events"
removeLabel={(option) => `Stop emailing me about ${option.label}`}
statusText={(count) =>
count === 1 ? '1 event matches' : `${count} events match`
}
/>
</Field>
<p className="text-base-content/60 text-sm">
Chosen: {events.length === 0 ? 'nothing yet' : events.join(', ')}
</p>
</div>
)
}The same combobox against a listbox that is aria-multiselectable, holding a list of values instead of one. Enter toggles the highlighted row and the list stays open, so the second value costs one more Enter rather than another click.
A chosen value keeps its place in the list with aria-selected="true" and a filled box rather than leaving it. Removing rows as you pick them shifts every row below the one you are on, and the highlight the pattern promises is stable moves with them.
The chips render before the input inside the same box. Each has a remove control whose accessible name comes from removeLabel, which receives the option so the name can say what it removes. Backspace on an empty input removes the last chip, and removing one from its own button returns focus to the input.
className reaches the text input inside the box. wrapperClassName is the box itself, which is the element carrying the daisyUI input classes and the one you usually mean.
A value with no matching option still renders a chip, labelled with the value itself. Dropping the chip instead would leave a selection you hold and cannot see.
Modal
A native dialog opened with showModal(), so the top layer, Escape, the focus trap, and the focus return come from the platform rather than from this repository. This one is live: open it and try to tab out.
src/lib/docs/showcase/kit-modal.tsx
import { useState } from 'react'
import { Button } from '@/components/ui/Button/Button'
import { Modal } from '@/components/ui/Modal/Modal'
export function KitModalDemo() {
const [open, setOpen] = useState(false)
return (
<>
<Button className="btn-primary" onClick={() => setOpen(true)}>
Revoke access
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Revoke access"
closeLabel="Close"
>
<p className="text-base-content/70 text-sm">
Tab around: focus stays inside this dialog. Press Escape, click outside, or
use the close button, and focus returns to the button that opened it.
</p>
<div className="modal-action">
<Button className="btn-ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button className="btn-error" onClick={() => setOpen(false)}>
Revoke
</Button>
</div>
</Modal>
</>
)
}Modal is a native <dialog> opened with showModal(). The top layer, Escape, the move of focus into the dialog, the focus trap while it is open, and the return of focus to the trigger on close are all the browser's. The component does not reimplement any of them.
open drives that method call rather than the open attribute. The attribute produces a non-modal dialog with no top layer, no Escape, and no focus trap, so it is never rendered here.
onClose runs for every way out: Escape, a click on the backdrop, and the close button. Set your own state from it, or the dialog closes itself and your open stays true.
title renders the <h2> that names the dialog through aria-labelledby, and closeLabel is the close button's accessible name. Both are required, because no file under src/components/ui/ imports t() and a dialog with no name is the defect this component exists to prevent.
The backdrop closes on a click, and its control is deliberately out of the tab order and out of the accessibility tree: Escape is the keyboard route, and a second control named like the close button would be announced twice.
Drawer
The same dialog as the modal, placed against one edge and full height. The daisyUI drawer classes are the checkbox hack, which has no Escape and no focus management, so modal side placement is used instead.
src/lib/docs/showcase/kit-drawer.tsx
import { useState } from 'react'
import { Button } from '@/components/ui/Button/Button'
import { Drawer } from '@/components/ui/Drawer/Drawer'
export function KitDrawerDemo() {
const [open, setOpen] = useState(false)
return (
<>
<Button className="btn-primary" onClick={() => setOpen(true)}>
Open the filters
</Button>
<Drawer
open={open}
onClose={() => setOpen(false)}
title="Filters"
closeLabel="Close"
>
<p className="text-base-content/70 text-sm">
A full-height sheet with the same obligations as the modal: Escape closes
it, focus is trapped while it is open, and focus returns to the button that
opened it.
</p>
</Drawer>
</>
)
}A Drawer is a Modal against one edge, so it takes every prop Modal takes and carries the same obligations. side is end by default, which is the trailing edge in both writing directions.
The sheet is w-80 and never wider than 85vw; pass boxClassName for another width. daisyUI sets width: auto on a side-placed modal box, which sizes a sheet to its longest line.
daisyUI's drawer classes are not what this uses. Every visible state of .drawer-side is written behind .drawer-toggle:checked ~ .drawer-side, so the sheet opens only while a hidden checkbox is checked, with no Escape and no focus management, and inside a <dialog> it renders nothing at all. modal-start and modal-end give the same full-height sheet, the same slide-in, and the same RTL flip, on top of the dialog behaviour.
Tooltip
daisyUI already shows a tip on hover and on keyboard focus. What this adds is the part CSS cannot do: a real element the trigger points at with aria-describedby, and Escape to dismiss it.
src/lib/docs/showcase/kit-tooltip.tsx
import { Button } from '@/components/ui/Button/Button'
import { Tooltip } from '@/components/ui/Tooltip/Tooltip'
export function KitTooltipDemo() {
return (
<div className="flex flex-wrap items-center gap-4">
<Tooltip
className="tooltip-bottom"
tip="Copied to your clipboard. Nothing is sent anywhere."
>
{(props) => (
<Button {...props} className="btn-ghost" onClick={() => {}}>
Copy token
</Button>
)}
</Tooltip>
<Tooltip
className="tooltip-bottom"
tip="Escape hides this one, and leaving brings it back."
>
{(props) => (
<Button {...props} className="btn-primary" onClick={() => {}}>
Tab to me
</Button>
)}
</Tooltip>
</div>
)
}The child is a function. It receives aria-describedby and four event handlers, and you spread them onto the trigger, the way useFieldProps hands Input its wiring instead of applying it. A description on a wrapper that cannot take focus is never announced, which is why the trigger has to take these.
daisyUI already shows a tip on hover and on :has(:focus-visible), so showing stays in CSS, where the browser decides what counts as keyboard focus. What the component adds is the rest of the pattern: tooltip-content is a real element with an id, so aria-describedby can point at it, where a data-tip tip is a ::before pseudo-element that no screen reader reads. Escape removes that element, which hides the tip and its tail and leaves nothing referenced; moving away re-arms it.
Placement is daisyUI's. Pass tooltip-top, tooltip-bottom, tooltip-left, or tooltip-right through className.
A tooltip on a disabled control never appears. A disabled element takes no focus and receives no pointer events, so neither CSS nor this component can invoke it. Leave the control enabled and refuse the action instead.
Toast
A queue, a portal, and a hook you call from an event handler. The two live regions under the stack are what make it announce; hover it and the countdown stops, and the one with an action waits for you.
src/lib/docs/showcase/kit-toast.tsx
import { Button } from '@/components/ui/Button/Button'
import { useToast } from '@/components/ui/Toast/Toast'
export function KitToastDemo() {
const { toast } = useToast()
return (
<div className="flex flex-wrap gap-3">
<Button
className="btn-primary"
onClick={() =>
toast({
message: 'Settings saved.',
variant: 'success',
dismissLabel: 'Dismiss',
})
}
>
Success
</Button>
<Button
onClick={() =>
toast({
message: 'Your trial ends in three days.',
variant: 'warning',
dismissLabel: 'Dismiss',
})
}
>
Warning
</Button>
<Button
onClick={() =>
toast({
message: 'That invitation could not be sent.',
variant: 'error',
dismissLabel: 'Dismiss',
})
}
>
Error
</Button>
<Button
onClick={() =>
toast({
message: 'Invitation sent.',
dismissLabel: 'Dismiss',
action: { label: 'Undo', onClick: () => {} },
})
}
>
With an action
</Button>
</div>
)
}Toast is the one Kit component that is a subsystem rather than a styled element: a provider registered in src/providers.tsx, a portal, and a hook you call from an event handler.
import { useToast } from '@/components/ui/Toast/Toast'
const { toast } = useToast()
toast({
message: t('Settings saved.'),
variant: 'success',
dismissLabel: t('Dismiss'),
})
A hook rather than a module-level toast() function, because a singleton is module state and this app renders on the server, where module state outlives the request that filled it. toast is stable, so a handler or a TanStack Query mutation callback can close over it.
variant is info, success, warning, or error, and it decides more than the colour: an error is announced assertively and everything else politely. That is why it is a prop here when btn-primary is a class everywhere else in the Kit.
message, dismissLabel, and an action's label are yours to write, as everywhere else under src/components/ui/.
A message clears itself after durationMs, five seconds by default. Hovering the stack or tabbing into it stops the countdown where it stands, and leaving resumes it. A toast carrying an action never clears itself at all, because a message you have to answer must not vanish while you read it; activating the action runs your handler and then dismisses it.
Two live regions sit under the stack at all times, one status and one alert, and both are empty until something arrives. This is the whole reason the component is a provider. A live region has to be in the accessibility tree before its content is inserted, so a region that mounts together with its message is an announcement that never happens, and one region cannot switch its role between polite and assertive either.
Errors therefore stack under informational messages rather than in arrival order. aria-atomic is false on both regions, against the default for those roles, so a second message is announced on its own instead of re-reading every message still on screen.
The stack is portalled to document.body. .toast is position: fixed, and a fixed element inside an ancestor with a transform, a filter, or contain positions against that ancestor instead of the viewport, so a layout change would otherwise move every toast in the app.
To add a component of your own, read The Kit.
