Button
Triggers an action or event, such as submitting a form or opening a dialog.
import { ... } from '@corndev/ui' HTML
No JavaScript required — these classes are the whole component.
<button class="corn-button" data-variant="button" data-level="primary" data-emphasis="solid" data-size="md">Save</button>
<!-- Red, and not shouting: the per-row destructive action in a table. -->
<button class="corn-button" data-variant="button" data-level="danger" data-emphasis="quiet" data-size="md">Delete</button>
<button class="corn-button" data-variant="icon" data-level="danger" data-size="md" aria-label="Delete">
<svg class="corn-icon" width="16" height="16" aria-hidden="true"><use href="#trash"></use></svg>
</button>
Classes
| Class |
|---|
.corn-button |
Attributes
| Attribute | Values |
|---|---|
data-size | sm|md|lg |
data-variant | icon|unstyled |
data-level | primary|secondary|success|danger|warning|ghost|neutral |
data-emphasis | solid|soft|outline|quiet |
data-full-width | present or absent |
data-loading | present or absent |
Vanilla JS
Optional. createButton only adds and removes the classes and attributes above, so the HTML is equivalent. Reach for it when you need to change options at runtime.
import { createButton } from '@corndev/ui';
import '@corndev/ui/theme';
const el = document.querySelector('#target');
const instance = createButton(el, { /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
variant | 'button'|'icon'|'unstyled' | 'button' | What kind of control this is. icon makes it square and drops the label padding, for a control whose whole content is an icon. Independent of level. unstyled has no appearance at all — for making a whole card or row clickable — and ignores level and emphasis, since it has no fill. |
level | 'primary'|'secondary'|'success'|'danger'|'warning'|'neutral'|'ghost' | 'primary' | Which colour. ghost is deprecated — it is the one value that names a fill rather than a colour, and it means level='primary' with emphasis='outline'. neutral has no hue: the text’s own colour, for an action that should not read as a status or the accent — with emphasis: 'quiet', a plain grey hover on text-coloured ink. |
emphasis | 'solid'|'soft'|'outline'|'quiet' | 'solid' | How much of that colour is filled in, independently of which colour it is. solid is a filled block, soft a tint with the colour as the text, outline a border and no fill, quiet neither — for a destructive action in a table row that should not shout. Hover escalates one rung; the hit area is the same at every rung. |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
loading | boolean | false | |
fullWidth | boolean | false | Fill the row. An unstyled button becomes a block, for a list entry or a whole card that is one control. |
type | 'button'|'submit'|'reset' | 'button' |
React
Button — from @corndev/ui-react.
The props are the same as the options above, plus className.
Icon-only
variant="icon" makes the button square and drops the label padding. It is a
separate dimension from variant, so an icon button still picks its colour from
the same ladder — a red destructive icon is level="danger" variant="icon".
<Button variant="icon" aria-label="Edit"><EditIcon /></Button>
<Button variant="icon" level="danger" aria-label="Delete"><TrashIcon /></Button>
<Button variant="icon" level="ghost" size="lg" aria-label="More"><DotsIcon /></Button>It needs an aria-label. An icon-only button with no accessible name is
invisible to a screen reader, and it is the most commonly shipped accessibility
defect in dense UIs. Both runtimes warn when one is missing rather than
inventing a default, because there is no safe default to invent.
The control is sized by hit area, not by its glyph: 28, 32 and 40px for sm,
md and lg. Sizing to the icon produces a button that is visible and
practically unclickable.
Variants
<Button level="primary">Primary</Button>
<Button level="secondary">Secondary</Button>
<Button level="success">Success</Button>
<Button level="danger">Danger</Button>
<Button level="warning">Warning</Button>
<Button level="ghost">Ghost</Button>Emphasis
level is the colour. emphasis is how much of it is filled in. They
are independent, which is what lets a destructive action be quiet — a red
“Delete” in a table row that does not shout at every row.
<Button level="danger" emphasis="solid">Solid</Button>
<Button level="danger" emphasis="soft">Soft</Button>
<Button level="danger" emphasis="outline">Outline</Button>
<Button level="danger" emphasis="quiet">Quiet</Button>Every colour crosses with every rung:
<Button level="success" emphasis="soft">Success</Button>
<Button level="warning" emphasis="quiet">Warning</Button>Hover escalates one rung — quiet gains the tint, soft and outline fill in solid. The hit area never changes. Removing the fill does not remove the padding, so a quiet button is the same 28px box as a solid one and still meets the 24×24 target WCAG 2.2 asks for.
Every colour × rung was measured in Chromium in both themes, resting, hovered and disabled: 120 combinations, all at or above 4.5:1 for text.
Without React
<button class="corn-button" data-level="danger" data-emphasis="quiet" data-size="md">
Delete
</button>
level="ghost" is deprecated
ghost was the one value of level that named a fill rather than a colour, and
its colour was hardcoded to primary — so an outlined danger button could not be
written at all. It now has a name that says what it is:
- <Button level="ghost">Cancel</Button>
+ <Button level="primary" emphasis="outline">Cancel</Button>
ghost still renders exactly as before, so nothing breaks and there is no
deadline. It is simply the only value that cannot be combined with an
emphasis.
Unstyled
variant="unstyled" removes the appearance entirely. It is for making a whole
region clickable and keyboard-reachable — a card, a history row — without it
looking like a control:
<Button variant="unstyled" onClick={() => open(project)} style={{ width: '100%' }}>
<Paper border padding="md">
<Text weight="semibold">{project.name}</Text>
<Text size="sm" muted>{project.path}</Text>
</Paper>
</Button>
It is not level="ghost" — ghost is still a button, with padding and a hit area
of its own. An unstyled button has no fill, so level and emphasis don’t apply
and aren’t emitted. What stays is behaviour: the focus ring, disabled and loading,
and a 24 × 24 minimum target.
Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>Next to other controls
At each size every single-line control is one height — Button, icon Button,
Input, Select and Combobox all take --corn-control-height-{sm,md,lg} — so a
toolbar or a field with a button beside it lines up without adjustment.
A split button is a text half and an icon half drawn as one control. Put them side by side with no gap and square off the corners where they meet:
<div className="split">
<Button>Save</Button>
<Button variant="icon" aria-label="More save options">▾</Button>
</div>
.split { display: inline-flex; }
.split > .corn-button:not(:last-child) { border-start-end-radius: 0; border-end-end-radius: 0; }
.split > .corn-button:not(:first-child) { border-start-start-radius: 0; border-end-start-radius: 0; }
States
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>Form submit
<Button type="submit" level="primary">Submit form</Button>