All pages

Use and extend the Kit

Files you will touch, in this order:

  • apps/web/src/components/ui/<Name>/<Name>.tsx, the component
  • apps/web/src/lib/docs/showcase/<name>.tsx, its demo
  • apps/web/src/lib/docs/showcase/index.ts, the demo registry
  • apps/web/src/content/docs/reference/components/kit.md and 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.

  1. Write the component

    src/components/ui/<Name>/<Name>.tsx, PascalCase folder and file. Extend the native element's props, forward the ref, and merge className last:

    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. Toast is the one that ships, and it takes its className in 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 ref is an ordinary prop, so spreading rest forwards it. No forwardRef is needed.

    To pick up a Field's id, description, and invalid state, read them the way Input does:

    const { isInvalid, ...field } = useFieldProps({ id, describedBy, invalid })

    Outside a Field every value is undefined, which is what keeps the control usable on its own with an aria-label.

  2. 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-activedescendant tracks it.
    • A native element that already does all of this is the correct implementation. Button renders a real <button> or a real <a>; Select renders 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.

  3. 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} />
    }
  4. 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.md and 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.

Warning

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.

Updated