Button
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/button.jsonThe component lands in components/ui/button.tsx and is yours to edit.
Variants
Section titled “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. icon is a glyph alone,
and needs an aria-label.
link reads as a link — “Show all”, “Undo”, “Open →” in a widget’s header or a
line of text — and still acts rather than goes. It takes no size: it sits in
the text it is in, at that text’s size and height, with no box of its own, and
an icon inside it is sized in ems so it follows the line. A size passed to it
is ignored rather than fighting the text. For one that goes somewhere, render
the link itself: render={<a href="…" />}.
<p className="text-sm text-dim"> Twelve works have no score. <Button variant="link">Show them</Button></p>Three text sizes and three icon sizes. An icon button is square by construction rather than by a padding that happens to match.
| Size | Height | Icon | For |
|---|---|---|---|
md |
h-control — 36px, 32 compact, 40 comfortable |
16px | the default: a toolbar, a form, a dialog’s actions |
sm |
h-control-sm — 32px, 28 compact, 36 comfortable |
14px | beside a field at sm, a dense toolbar |
xs |
24px | 12px | inside something — a chat line, a chip, a table cell |
icon-md |
32px square | 16px | |
icon-sm |
28px square | 14px | a title bar, a panel header |
icon-xs |
20px square | 12px | inside a row; the hit area stays 24px |
md and sm stand on the control rows, the same rows every field of the
set stands on, so a button beside a field of the same size is the same height
— and data-density on a container reaches the button along with the field.
They used to be h-9 and h-7 literally, and a compact form came out with
32px fields beside 36px buttons. See
control height and density.
xs and icon-xs are below the pointer floor on purpose and grow their
hit area to 24px with target-min, so the glyph stays small and the target
does not.
An icon in a text button
Section titled “An icon in a text button”Put the icon in and give it no size: every size sizes the icon inside it —
16px at md, 14 at sm, 12 at xs.
<Button variant="primary"> <Plus /> New style</Button>A lucide icon draws at its own 24px unless something sizes it, and a text
button used to size nothing — so every icon in a text button across the line
was taller than the text beside it, fixed at each call site by a size-4 that
the next call site forgot.
An icon with a size of its own keeps it. The button sizes only an svg with
no size-* class: [&_svg:not([class*=size-])]:size-4. Without the guard the
rule is a descendant selector — one class and one element — and it outranks
the single class on the icon, so <Plus className="size-2.5" /> in an icon
button drew at 14px and nothing said why. The same guard is on every primitive
of the set that sizes its icons, and a gate holds it.
States
Section titled “States”Disabled keeps the button’s own colour and fades, so it reads the same
whatever the product’s accent is. It is drawn from data-disabled, and every
hover is scoped away from it — so a disabled button does not light up under the
pointer, and the pointer still reaches it.
A disabled button can say why. Give it disabledReason and it stays in the
tab order and under the pointer, a press does nothing (and does not submit its
form), and the reason is both its title and its accessible description:
<Button disabled={orphaned} disabledReason={t('trash.cannotRestore')}> {t('trash.restore')}</Button>A plain disabled button is a wall: skipped by Tab, silent to a reader, and —
while it was pointer-events: none — deaf to its own title, which is how
kilna’s trash came to explain in a tooltip nobody could ever raise why a row
could not be restored. With a reason it is an instruction. It is
aria-disabled rather than disabled for exactly that: a native disabled
control cannot be focused, so the one reader who most needs the reason could
never reach it.
The reason is not inside the button — text inside a button is part of its
name, and the button is still called “Restore”. It sits beside it, hidden,
where aria-describedby reads it. For a styled bubble rather than the
browser’s, wrap the button in a Tooltip: it can
open, because the button is still reachable.
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 <button> painted to look like
a link does none of that.
<Button render={<a href="/somewhere" />} variant="primary"> Go</Button>It is a real button. Enter and Space activate it, Tab reaches it, and a
disabled one without a reason is skipped by the keyboard and does nothing. None of that
is written into the component — it comes free with the element, and is exactly
what a <div onClick> throws away.
type defaults to button. A bare <button> inside a form submits it,
which surprises everyone once. Pass type="submit" when that is what you want.
The caller wins a conflict. className is merged by utility group, so
<Button className="rounded-full"> gets round corners rather than two radii
fighting over source order.
No colour of its own. Every variant is written in tokens, so the same class
list is correct in both themes and in every product’s accent — there are no
dark: utilities anywhere in it.
Props
Read from the types in ui/button.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
| Prop | Type | Default | |
|---|---|---|---|
variant | primary | ghost | soft | danger | icon | link | 'ghost' | |
size | xs | sm | md | icon-xs | icon-sm | icon-md | 'md' | |
disabledReason | string | Why the button cannot be pressed, in the product's words - read only while disabled. | |
render | RenderProp<Record<string, unknown>> | Render something else with the button's clothes on - a link, most often. | |
disabled | boolean | false |
And every prop of React's ButtonHTMLAttributes<HTMLButtonElement>, passed on.
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 16 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/button.tsx - the file a product copies - newest first.