Skip to content

Button

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/button.json

The component lands in components/ui/button.tsx and is yours to edit.

See it liveEvery variant, size and state - in either theme, and in the accent of any product of the line.→

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.

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.

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.

PropTypeDefault
variantprimary | ghost | soft | danger | icon | link'ghost'
sizexs | sm | md | icon-xs | icon-sm | icon-md'md'
disabledReasonstringWhy the button cannot be pressed, in the product's words - read only while disabled.
renderRenderProp<Record<string, unknown>>Render something else with the button's clothes on - a link, most often.
disabledbooleanfalse

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.

  1. v0.33.0
    • feat a link variant, and a disabled button that says why 8639f9b
  2. v0.32.0breaking
    • feat stand on the control rows and size the icon at every size 71ce281
  3. v0.8.0breaking
    • feat compose with `render`, and drop Radix ef564a7
  4. v0.4.0
    • feat add the component pipeline and Button cf78880