Files you will touch, in this order:
apps/web/src/components/ui/<Name>/<Name>.tsx, the componentapps/web/src/lib/docs/showcase/<name>.tsx, its demoapps/web/src/lib/docs/showcase/index.ts, the demo registryapps/web/src/content/docs/reference/components/kit.mdand its Danish counterpart
src/components/ui/ is the Kit. Everything beside it under src/components/ is the storefront, which you are expected to rewrite. The split is the reason docs/adr/0034-component-kit-extends-daisyui.md gave the directory a name.
Use one
Import it and pass daisyUI's own classes for colour and size:
import { Button } from '@/components/ui/Button/Button'
<Button className="btn-primary btn-block" onClick={start}>
{t('Get the boilerplate')}
</Button>
The Kit adds no colour vocabulary of its own. There is no variant="primary" to learn, because btn-primary already exists and a prop that only forwards it would be one more name for the same thing. What Button adds is the part daisyUI has no class for: the hover group, the loading state, the type="button" default, and the branch between a link and a button.
Every visible string is a prop. No file under src/components/ui/ imports t(), so you translate at the call site, as the line above does.
Override one
className merges last through cn, so your class wins:
<Select className="input-ghost input-sm w-auto" aria-label={t('Language')} {...props} />
That is LanguageSelect, which is in the header rather than in a form and overrides both of the Kit's defaults.
One caveat, and it is the one that bites. cn resolves Tailwind groups, so w-auto replaces the Kit's w-full. It does not resolve conflicting daisyUI modifiers, so passing btn-ghost to a component whose base already sets btn-primary applies both and the result depends on stylesheet order. Pick one daisyUI modifier per element. No Kit component sets a colour modifier for exactly this reason.
When className is not enough, edit the component. You own the file and there is no upstream to merge against.
Add one
A component belongs in the Kit when it owns a decision the theme cannot hold, or fills a hole daisyUI has no class for. A wrapper that only forwards values fails that test however often you use it, and belongs at its call site instead.
-
Write the component
src/components/ui/<Name>/<Name>.tsx, PascalCase folder and file. Extend the native element's props, forward the ref, and mergeclassNamelast:A component that is a subsystem rather than an element takes the second shape instead: a provider, and a hook that hands its callers an imperative function.
Toastis the one that ships, and it takes itsclassNamein the options a caller raises a message with. Reach for it only when there is no element to hang the behaviour on.import { cn } from 'cn' import type { ComponentProps } from 'react' export function Textarea({ className, ...rest }: ComponentProps<'textarea'>) { return <textarea className={cn('textarea w-full', className)} {...rest} /> }In React 19
refis an ordinary prop, so spreadingrestforwards it. NoforwardRefis needed.To pick up a
Field's id, description, and invalid state, read them the wayInputdoes:const { isInvalid, ...field } = useFieldProps({ id, describedBy, invalid })Outside a
Fieldevery value isundefined, which is what keeps the control usable on its own with anaria-label. -
Meet the accessibility bar
Name the WAI-ARIA Authoring Practices pattern your component implements, in a comment at the top of the file, and then implement it:
- Every control has an accessible name.
- Anything that opens closes on Escape.
- While something is open, focus stays inside it and returns to the trigger on close.
- Arrow keys move the active option, and
aria-activedescendanttracks it. - A native element that already does all of this is the correct implementation.
Buttonrenders a real<button>or a real<a>;Selectrenders a real<select>.
None of this is a lint rule or a build gate. A missed obligation is a defect to fix, not a red build.
-
Write its demo
src/lib/docs/showcase/<name>.tsx, kebab-case, exporting one component that takes no props. This file is printed under the rendered frame, so it is also the answer to "how do I call this", and anything the component needs arrives as a fixture written in the demo:import { Textarea } from '@/components/ui/Textarea/Textarea' export function KitTextareaDemo() { return <Textarea aria-label="Notes" rows={3} /> } -
Register it and reference it
Add the entry to
src/lib/docs/showcase/index.ts, where the key must match the filename:demo( 'kit-textarea', KitTextareaDemo, 'The Kit textarea. One sentence, printed under the frame and printed ' + 'instead of the render in /llms-full.txt.', )Then add a section to
src/content/docs/reference/components/kit.mdand to the Danish file beside it:## Textarea :::demo{name="kit-textarea"}
:::
## Check it worked
```bash
pnpm check && pnpm typecheck
Then open /docs/reference/components/kit and look at the frame. The gallery reads the demo's source off disk, so a component that fails to render breaks its own documentation page, and that is the closest thing to a render check this repository has. No test suite ships.
Rendering once proves the component mounts. It does not prove a focus trap works or that Escape dismisses. Use the frame with a keyboard, not just with your eyes.