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

AttributeValues
data-sizesm|md|lg
data-varianticon|unstyled
data-levelprimary|secondary|success|danger|warning|ghost|neutral
data-emphasissolid|soft|outline|quiet
data-full-widthpresent or absent
data-loadingpresent 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

OptionTypeDefaultDescription
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'
disabledbooleanfalse
loadingbooleanfalse
fullWidthbooleanfalseFill 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>