ComponentsActions & navigationButton
On this page
Actions & navigation

Button released

A button in five faces, four tones and three sizes — or a square around one glyph — drawn on the platform's own <button>, or on its <a>, when the control navigates.

  • Import@pacit/components/button
  • Selectorsbutton[pctButton], a[pctButton]

Semantics come from the native <button> — no role and no keyboard handling of ours

button.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctButton } from '@pacit/components/button';
ExportedWhat it is
PctButtoncomponentbutton[pctButton], a[pctButton]Button. An attribute selector on a native <button> — or on a native <a>, when the control navigates — so semantics, keyboard handling and focus work natively (req-a11y-built-in). The paint is the same on both; what differs is the element, and the element is the consumer's (0071).
PctButtonVarianttype'solid' | 'outline' | 'ghost' | 'soft' | 'hero'How much of the page's colour a button takes: solid, outline, ghost, soft, or hero — the one loud face, with the gradient that drifts.
PctButtonSizetypePctSizeThe size of a button: the library's own scale.
<button pctButton>Save</button> <a pctButton href="/start">Get started</a>
<button pctButton iconOnly variant="ghost" aria-label="Delete the draft">
  <pct-icon icon="trash" />
</button>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

Faces

variant picks the paint. Solid carries the primary action, outline and ghost sit beside it, soft is the quiet tint — and hero is the one that drifts, reserved for the single most important call on a page.

Tones

tone picks which of the skin's families the face paints from — the same four names the toast and the progress bar wear. Move the choice and watch the four faces follow: there is no primary tone, because a button without one already is the brand, and None is what every button here is by default.

The fifth face is missing from the row on purpose. hero is the brand gradient and takes no tone at all; ask it for one and it says so in dev mode rather than quietly ignoring you.

The colour is never the message. "Delete account" says danger in words, and that is what a reader hears — a red button labelled "OK" tells nobody anything they could not see.

A destructive action, and the way out of it

The tone is for the one press that cannot be taken back — and it works because of what stands beside it. The way out is the quiet face with no tone at all: a row where both buttons shout has no shape, and a reader who cannot see the red is left with the two labels, which is why the labels have to be the ones carrying the difference.

soft is the same tone at a lower volume, for the destructive action that is not the point of the screen — removing one row of a table rather than closing the account.

The dialog's own footer: one press that ends it, one that does not.

The same tones where the action is not the point of the screen.

Sizes

Three heights — 28, 36 and 44 px — shared with every field, so a button and an input on the same row line up to the pixel. The default comes from providePctConfig, not from the template.

Loading and disabled

Two states, kept apart on purpose. loading says "working": the spinner takes the face's own colour and the element reports aria-busy. disabled says "cannot be clicked". A queued click is a legitimate pattern, so loading alone never blocks — press Save to watch both at once.

Icon only

iconOnly makes the face a square as tall as its size, with the glyph on the icon's own step for it — every face, tone and state is the same button's. There is nothing on it to read, so the name is written on the button: a tooltip with pctTooltipAs="name" shows it and speaks it, aria-label only speaks it. Press refresh to watch the spinner take the glyph's place.

In a toolbar

A row of actions is the consumer's own flex box — pct-stack stacks, it does not row. The primary action goes last, the destructive one is kept at arm's length by the gap that grows between them.

In a form

The submit button is a native type="submit": the signal form's validity drives disabled, and the row heights match because the field and the button read the same size token. Clear the name to watch the button follow.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

unreleased marks 1 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.

Button. An attribute selector on a native <button> — or on a native <a>, when the control navigates — so semantics, keyboard handling and focus work natively (req-a11y-built-in). The paint is the same on both; what differs is the element, and the element is the consumer's (0071).

Inputs

NameTypeWhat it does
variantdefault 'solid'PctButtonVariantPicks the face. All five paint the same element; hero adds the drifting gradient and freezes it under reduced motion.
tonesince 0.2.0default nullPctTone | nullWhich of the skin's four families the face paints from — danger, warning, success, info — or null, the default, which is the brand. The names are shared with every other component that wears a tone rather than invented here ({@link PctTone}, 0076), and there is no primary member because primary is what a button with no tone already is.
sizedefault config.defaultSizePctButtonSizeHeight 28 / 36 / 44 px — the axis every field shares, so rows line up. From providePctConfig by default (req-api-config).
iconOnlyunreleaseddefault falsebooleanThe face for one glyph and no words: a square as tall as the size, the glyph drawn on the icon's own step for that size, and 24 px under both axes whatever a skin does to the height. Geometry only — every face, tone and state above is the same button.
disableddefault falsebooleanBlocks the click and greys the face; the grey is written to survive forced colors. On a link it is aria-disabled and a refused navigation — the platform has no disabled link.
loadingdefault falsebooleanShows the spinner in the face's own colour and sets aria-busy. Does not block the click — pair it with disabled when it should.

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-button
data-pct-variantvariant()
data-pct-toneappliedTone()Absent when there is no tone, and absent on the hero face whatever was asked for: an attribute the stylesheet must not answer is better not written than written and ignored.
data-pct-sizesize()
data-pct-icon-onlyiconOnly() ? "" : null
data-pct-loadingloading() ? "" : null
data-pct-disabledisDisabled() ? "" : nullThe face's own hook, written on both elements, because only one of them has a disabled attribute to key on and the stylesheet should not have to know which.
disabledisButton && isDisabled() ? "" : nullThe platform's own refusal, where the platform has one: on a <button> the attribute blocks the click, drops the element out of the tab order and says "disabled" in the accessibility tree without anything being written for it.
aria-disabled!isButton && isDisabled() ? "true" : nullAnd where it has none. A link cannot be disabled — aria-disabled says the state, onClick refuses the navigation, and the element stays focusable on purpose: a control a reader cannot reach cannot tell them why it does nothing.
aria-busyloading() ? "true" : nullOnly while the button actually works. aria-busy="false" is the default value, so writing it out adds nothing and stands in the accessibility tree of every button on the page.
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctButtonHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
label[data-pct-part="label"]the span around the projected content
spinner[data-pct-part="spinner"]the loading ring, present only while loading

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-button-bg{pct.primary}#2563eb · #60a5faBackground of the solid face.
--pct-button-bg-active{pct.primary-active}#1e40af · #bfdbfeSolid face background while pressed.
--pct-button-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of any face while disabled.
--pct-button-bg-hover{pct.primary-hover}#1d4ed8 · #93c5fdSolid face background on hover.
--pct-button-border{pct.primary}#2563eb · #60a5faEdge of the solid and outline faces.
--pct-button-border-disabled{pct.border}#e2e8f0 · #334155Edge while disabled.
--pct-button-fg{pct.on-primary}#ffffff · #0f172aLabel and spinner colour on the solid face.
--pct-button-fg-disabled{pct.text-disabled}#64748bLabel colour while disabled — measured on purpose, though SC 1.4.3 exempts it.
--pct-button-font-size{pct.font.size.md}14pxLabel size at size md.
--pct-button-font-size-lg{pct.font.size.lg}16pxLabel size at size lg.
--pct-button-font-size-sm{pct.font.size.sm}13pxLabel size at size sm.
--pct-button-font-weight{pct.font.weight.medium}500Label weight, every face.
--pct-button-height{pct.control.height.md}36pxHeight at size md — the control axis every field shares, so rows line up.
--pct-button-height-lg{pct.control.height.lg}44pxHeight at size lg.
--pct-button-height-sm{pct.control.height.sm}28pxHeight at size sm.
--pct-button-icon-size20pxGlyph size on the icon-only face at size md.
--pct-button-icon-size-lg24pxGlyph size on the icon-only face at size lg.
--pct-button-icon-size-sm16pxGlyph size on the icon-only face at size sm.
--pct-button-padding-x{pct.space.5}16pxHorizontal padding at size md.
--pct-button-padding-x-lg{pct.space.5}16pxHorizontal padding at size lg.
--pct-button-padding-x-sm{pct.space.4}12pxHorizontal padding at size sm.
--pct-button-radius{pct.radius.md}8pxCorner radius of every face.
--pct-button-target-min{pct.target.min}24pxSmallest height any face draws, and the smallest width of the square.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-button-bg: #0f766e;
  --pct-button-bg-hover: #115e59;
  --pct-button-fg: #ffffff;
  --pct-button-radius: 999px;
}
Accessibility

Measured, not assumed

Pattern: a native <button> — or a native <a> when the control navigates; no role of its own on either, and no keyboard handling of its own (req-api-platform)

Keyboard

None of its own. Enter and Space come from the native <button>; a link answers Enter alone, because that is what a link does. That is req-api-platform at work, not an oversight — and it is also why the two elements differ here rather than being made to agree.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

GapARIA APG pattern named in the class JSDocnone — gap: the JSDoc does not say outright that the component deliberately has no role of its own
MeasuredKeyboard map testedapps/sandbox-e2e/src/button.spec.ts — for the link alone, and only what is not the browser's: a real Tab reaches a disabled link, the ring it lights is the button's, and Enter on it navigates nowhere
Measuredaxe audit on its own sandbox viewapps/sandbox-e2e/src/a11y.spec.ts (the /button view, the Links card among them)
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts
MeasuredTone axislibs/components/button/src/button.spec.ts (the attribute, the hero refusal, the disabled state keeping it) and apps/sandbox-e2e/src/button.spec.ts (the paint: four tones × four faces, the grey that outranks a tone, the quiet face's hover ground, the soft face read at rest and under the pointer, and the press read on the SOLID face, where hover and active are different steps of the ramp — on the soft face they are one tint, so an assertion there would be satisfied by what hover already painted; it is the suite's only reading of :active on a button) and apps/sandbox-e2e/src/forced-colors.spec.ts (a toned face flattens to exactly what the untoned one flattens to, which is the whole reason a tone may not be the only channel) — the colours themselves are measured by libs/tokens/build.mjs against contrast.policy.json on every build, 32 pairs over two themes
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts and tools/check-styles.mjs (point 7) — the grey of the disabled state repeats the base rule's selector, or @media would not outrank it; loading keeps the variant's colour here too (lesson-70); the hero drops its gradient (an image survives the forcing, so it is dropped by hand) and the boundary-less faces get a ButtonText edge back — both read computed in three engines. A link is forced to LinkText on Canvas instead, so its edge is LinkText and its disabled state stays GrayText (0071)
MeasuredSC 2.2.2 — the hero variant's sweep endslibs/components/button/src/button.scss — one pass of half the motion axis (four seconds) and the gradient stands still. It was 8s and endless; what changed is that it now ENDS, inside the five seconds the criterion allows motion to run with no control beside it, and the cost is that it reads twice as brisk while it runs. The pass is measured on [pctHero], whose fill face is the same sweep (apps/sandbox-e2e/src/hero.spec.ts); the button has no paused input of its own — the settle is what answers the criterion, and the extra stop lives on the component whose whole job is the face
Measuredprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts — the spinner slows down (still says "working"), the hero drift freezes outright (--pct-motion-drift-duration → 0s: decoration informs of nothing) — the two ends of the same axis (0008)
MeasuredTouch target ≥ 24×24 pxapps/sandbox-e2e/src/target-min.spec.ts — both faces stand on --pct-button-target-min, and the spec reads each at 24 px with every height zeroed: the labelled button's height (its sizes clear it by themselves — 28 px at the smallest, 26 under compact density — and the floor is what a skin that shrinks them runs into; its width is its words and padding, because a width floor was measured letting it shrink past its label in a narrow row, which button.spec.ts now refuses) and the icon-only square on both axes, since it is only as wide as its height. apps/sandbox-e2e/src/button.spec.ts reads the squares, the glyph's step and the spinner in its place at every size, square in a row that squeezes or stretches it, in three engines (0085)
MeasuredSize axisapps/sandbox-e2e/src/size.spec.ts — measuring equality with the field row and the absolute value
MeasuredDensity axisapps/sandbox-e2e/src/density.spec.ts — at every size the button follows the field row into the compact scope (26 / 32 / 38 px), read beside it (0074)
MeasuredRTLtools/check-styles.mjs (point 5, req-token-logical) over the stylesheet — three physical declarations, each a justified pct-exception: two border-right-color for the spinner's gap (a ring has no writing direction) and one background-position for the hero's drift — plus the button-variants-rtl baseline in apps/sandbox-e2e/src/visual.spec.ts
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts through visit()
n/aFormsnot applicable
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json, libs/tokens/build.mjs
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — the component writes no strings of its own, and adding one fires the gate
MeasuredEntrypoint size budgetlibs/components/size.snapshot.md — the ./button row (req-project-tree-shaking)
MeasuredScreen-reader logRead 2026-09-16 from docs/acr/at/ — three readers, whole sandbox. Every variant announces as the same thing and nothing more: Outline · button. (Orca), Outline, button (NVDA), Outline button (VoiceOver), across solid, outline, ghost, soft and hero. That is the reading this card wanted — the variant is a look and a reader must not be able to hear it — and no reader did
Measureddocs page/components/button on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

92.54%mutation score124 of 134 mutants killed
22 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
45colour pairs measuredlight and dark, at every token build
9visual baselinesChromium, the sandbox's own pictures
13elements in the preview0 listeners · 1 render to settle · 4558 µs in jsdom, held exactly but for the clock

Why it is built this way

Five faces on two native elements. Each keeps its own semantics, its own keyboard handling and its own place in a form or in a crawler's index — the directive only paints, and every face is measured on both themes before it ships. A link wearing the face is still a link (0071): the role, the middle click and the address bar's preview are all still there, and only the underline goes.

Decisions behind it

  • 0001Template and styles in separate files
  • 0004Height stated outright, not derived from padding
  • 0008The motion axis in tokens
  • 0058The hero face is paint, and a gradient is three contrast checks
  • 0071A link in button's clothes is a link
  • 0076A tone is two channels and four names, settled once
  • 0082A tone on a button is a face, and the label is what speaks
  • 0085An icon-only button is a face, and its name is written on it
  • lesson-70A media query adds no specificity, and the browser it is written for hides that

Known limitations

  • A tone is a face, and nothing announces it. tone="danger" paints; it writes no ARIA, and a reader hears "Delete account, button" whatever the colour. That is the reading the screen-reader row below records for the variant axis, and it holds for this one (0082) — which means a toned button whose LABEL does not carry the meaning says nothing to anyone who cannot see the colour, and no attribute here can repair that.
  • hero takes no tone — the attribute is not written for it and dev mode says so once.
  • info and the default look the same in this skin — deliberately: info and primary are the same blue, and only the role is separate.
  • An icon-only button's name is not the compiler's to demand. iconOnly makes a square around one glyph and nothing on it can be read, so the name is written on the button — aria-label, or pctTooltipAs="name", which shows it as well. An attribute on an element is not something a type can require: dev mode reports a square with no name at all, once, after the first render — a heuristic that errs towards silence — and the rest is the axe audit in the consumer's own suite (0085).
  • No pressed face. aria-pressed on the native element is the platform's and is announced, but no face paints it — and an icon-only button is the most common toggle there is. A pressed state belongs to every button and is its own decision (0085).
  • A labelled button's width stands on no floor. Its height does; its width is its words and its padding, because a width floor was measured letting buttons shrink past their labels in a narrow row. A skin that zeroes --pct-button-padding-x around a one-character label draws a target narrower than 24 px, and no gate says so — that is what iconOnly is for (0085).
  • A glyph beside words is not the square's glyph. A pct-icon in a labelled button stays 1em of the label (13 / 14 / 16 px); the square draws its glyph at 16 / 20 / 24. A toolbar that mixes the two shows two scales.
  • loading does not block the click — disabled does. Deliberately separated: "working" and "cannot be clicked" are two different states, and a loading button without disabled is a legitimate pattern (when a click queues something, say). On a link the pair behaves the same way: loading alone announces aria-busy and lets the navigation through.
  • A disabled link is a promise, not a platform state — HTML has no disabled link, so disabled on a[pctButton] is aria-disabled, the greyed face and a refused press, and the element deliberately keeps its place in the tab order (0071). A consumer who needs it gone from the walk removes the href, which is the platform's own way of saying so.
  • <a pctButton> with no href is reported, not repaired — it paints like a button and has no role, no tab stop and no keyboard. A dev-mode sentence names it once after the first render; nothing in the type system or in CSS can see it.