Button

Updated 2 weeks ago

Live preview

primary

secondary

ghost

critical

Button — Accessibility

Target: WCAG 2.2 AA, AAA where achievable. Corrected 2026-07-15 — this file previously claimed a blanket AAA target and false contrast numbers (recomputed directly from the real shipped hex values, not carried forward from an earlier, unverified draft).

Conformance status

CriterionLevelStatus
1.4.3 Contrast (Minimum) — text vs backgroundAAPass — primary variant 6.66:1; critical variant (resting) 7.33:1 as of the 2026-07-15 interactive-critical fix (previously 4.44:1, a real AA failure — see Fixed defects below)
1.4.6 Contrast (Enhanced)AAAPartial — critical variant (7.33:1) passes; primary variant (6.66:1) does not meet the 7:1 AAA floor, AA only
1.4.11 Non-text Contrast — focus ringAAPass — 3:1 minimum against any tenant background
2.1.1 KeyboardAPass — Tab to focus, Space/Enter to activate
2.4.7 Focus VisibleAAPass — 2px ring, 2px offset
2.5.5 Target Size — 44x44 minimumAAAPass — 40px height + 2px ring + 2px offset = 44px
4.1.2 Name, Role, ValueAPass — native <button> element

Fixed defects

2026-07-15 — Critical variant resting-state contrast, real AA failure. theme.semantic.interactive-critical resolved to {color.critical-50} (#d24747), which computes to 4.44:1 against white — below the 4.5:1 AA floor for normal text, a real defect (this recipe's white ink-on-critical text was genuinely under-contrasted, not just a documentation error). Found during the v3 design.pointsav.com mockup's factual-grounding audit (round 11, 2026-07-11), flagged rather than silently patched at the time since the fix belongs in the token layer, not the mockup. Fixed here: interactive-critical moved to {color.critical-60} (7.33:1, matching the resting-state color this component's own hover state already used), interactive-critical-hover moved to {color.critical-70} (10.59:1) to preserve a distinct hover step; interactive-critical-pressed unchanged ({color.critical-70} — now identical to hover, since the primitive scale has no darker step below critical-70; accepted rather than inventing an unratified value).

Keyboard interactions

KeyBehaviour
TabMove focus to the button
Shift + TabMove focus to the previous focusable element
SpaceActivate the button
EnterActivate the button
Escape (in dialog context)Cancel the dialog without activating

Screen reader behaviour

The native <button> element is announced as "button" with the visible label. For icon-only buttons, set aria-label with the action description. The substrate's recipe never overrides the native role with role="button" on a non-button element; that pattern is an anti-pattern that breaks keyboard activation.

When a button enters a loading state (subsequent milestone), aria-busy="true" and aria-disabled="true" are set; the visible label updates to reflect the in-flight state.

Reduced motion

Buttons respect prefers-reduced-motion: reduce automatically — the CSS transition-duration value resolves to 0 when the media query matches. No code change required at the consumer level.

@media (prefers-reduced-motion: reduce) {
  .ps-btn { transition: none; }
}

Touch targets

All button variants meet the 44x44 minimum touch target. The component renders at 40px height; the focus ring offset adds 4px on each side, bringing the activatable area to 44px+. Dense-grid contexts that need smaller buttons should use the Ghost variant in a context that does not require touch — Ghost in a toolbar above a keyboard-focused work surface is acceptable; Ghost in a mobile-first form is not.

Colour independence

No button state is communicated by colour alone. Hover adds a darker background tone; pressed adds a darker still; disabled changes both background AND text colour AND removes the focus ring. Critical variants are marked by colour AND by their positioning (always paired with a confirmation step) AND by their label semantics (Delete account, Discard changes).

Anti-patterns

  • <div onclick> instead of <button>. Loses keyboard activation, screen-reader role, focus management. Always use <button>.
  • Disabled without explanation. The user has no way to know what would unlock the action. Surface the constraint near the trigger instead.
  • Critical variants with non-destructive labels. The visual weight is reserved for destructive actions. Don't use Critical for "Submit" or "Apply".
  • Focus ring removed. outline: none without an alternative breaks 2.4.7. The recipe never removes the ring.

Compliance audit endpoint

Future v0.0.3 work — the substrate will expose GET /api/audit/wcag?theme=<theme> returning a per-component WCAG conformance report. Today, this page is the canonical conformance statement.

Important Information

Design System disclosure

This site provides open-source design tokens, documentation, and self-hostable software published by Woodfine Capital Projects Inc. Information here is for general reference only and does not constitute an offer, warranty, or a guarantee of fitness for any particular purpose. Statements regarding planned, intended, or targeted future features are forward-looking and subject to change without notice; they are not undertaken to be updated except as required by law.