Canvas Design System
Main Site Tokens

Button

Triggers an action. The most-used interactive component on the platform, so its rules are the strictest.

When to Use

Use when: Something happens on click — save, delete, submit, open a modal — or when a navigation target is important enough to be a call to action. One primary per view; everything else is secondary or ghost.
Don't use when: The target is ordinary inline navigation — use a plain anchor. For switching between views use Tabs; for an on/off setting use Toggle.

Variants

Style

Four variants, in descending order of emphasis. danger is not more important than primary — it's for destructive actions only.

VariantUse for
primaryThe single most important action in the view
secondaryAlternative actions alongside the primary. Inherits --phase-color.
ghostLow-emphasis and repeated actions — toolbar controls, row actions
dangerDestructive, irreversible actions. Pair with a confirmation.
<?= ws_button('Primary', ['variant' => 'primary']) ?>
<?= ws_button('Secondary', ['variant' => 'secondary']) ?>
<?= ws_button('Ghost', ['variant' => 'ghost']) ?>
<?= ws_button('Danger', ['variant' => 'danger']) ?>

Sizes

<?= ws_button('Small', ['size' => 'sm']) ?>
<?= ws_button('Large', ['size' => 'lg']) ?>

With icons

The icon sits left by default. Put it right only when it signals forward motion, as on a "next" step.

<?= ws_button('Add Workshop', ['variant' => 'primary', 'icon' => 'add']) ?>
<?= ws_button('Next Step', [
    'variant'      => 'secondary',
    'icon'         => 'arrow_forward',
    'iconPosition' => 'right',
]) ?>

As a link

Passing href renders an <a> instead of a <button>. Use it when the action is really navigation — that keeps middle-click, right-click, and copy-link-address working.

<?= ws_button('View Workshops', ['variant' => 'primary', 'href' => '/library/workshops/']) ?>

Phase theming

The secondary variant reads --phase-color for its text and border, so a container can theme the buttons inside it. Since Red Unification every phase token resolves to the same red — the four below are deliberately identical, and that's the correct current behaviour.

<!-- Set --phase-color on any parent container -->
<div style="--phase-color: var(--phase-plan);">
    <?= ws_button('Plan', ['variant' => 'secondary']) ?>
</div>
warning Never hardcode a legacy phase hex as a fallback (var(--phase-plan, #0284C7)). The blue, purple, and emerald phase colours were retired — a fallback like that resurrects them wherever the token fails to load. See Colors.

States

StateBehavior
HoverPer-variant colour shift, gated on :not(:disabled). Primary also lifts with --button-shadow-primary-hover.
ActivePresses down via .ws-btn:active:not(:disabled).
FocusBrowser default focus ring — the platform's one sanctioned non-red accent. Don't remove it without providing a replacement.
DisabledSets the native disabled attribute, so the button is unclickable and skipped by Tab. Hover and active rules are suppressed.
LoadingAdds .ws-btn--loading, renders .ws-btn__spinner, and disables the button so it can't be double-submitted.
Full width.ws-btn--full stretches to the container. .ws-btn--half exists for narrow card columns but has no helper option — pass it through class.
warning disabled has no effect when href is set — anchors ignore the attribute, so a "disabled" link button stays clickable. For a disabled navigation action, render a real <button> or omit the control.

Real-World Usage

A modal footer — ghost cancel on the left, primary confirm on the right, and the confirm carrying the loading state while the request is in flight. This is the pattern Modal Footer encodes, so prefer that component over rebuilding it.

<?= ws_button('Cancel', ['variant' => 'ghost', 'attrs' => ['data-close' => '']]) ?>
<?= ws_button('Save Workshop', [
    'variant' => 'primary',
    'icon'    => 'save',
    'type'    => 'submit',
    'loading' => $isSaving,
]) ?>

Options

OptionTypeDefaultPurpose
$textstringPositional label. Lead with a verb: "Save workshop", not "OK".
variantstring'primary'primary | secondary | ghost | danger
sizestring'md'sm | md | lg
iconstringnullMaterial icon name
iconPositionstring'left'left | right
hrefstringnullRenders an <a> instead of a <button>
typestring'button'button | submit | reset. Set submit explicitly inside forms.
disabledboolfalseNative disabled state. Ignored when href is set.
loadingboolfalseShows the spinner and disables the button
fullWidthboolfalseStretch to the container width
idstringnullElement ID
classstring''Additional CSS classes
attrsarray[]Extra HTML attributes as key/value pairs

ws_icon_button($icon, $options) is a shorthand for an icon-only ghost button at sm. It passes an empty label, so give it an attrs => ['aria-label' => …] — see below.

Accessibility

ConcernBehavior
SemanticsRenders a real <button>, or an <a> when href is set. Both are natively focusable and correctly announced — no ARIA role is added or needed.
KeyboardNative: Tab to reach, Enter or Space to activate a button, Enter for a link.
FocusBrowser default focus ring. It's the sky-blue accent that survived Red Unification precisely because focus visibility outranks brand consistency.
DisabledThe native attribute removes the button from the tab order entirely. That's correct for form submission, but means keyboard users get no explanation — put the reason in adjacent text rather than a title.
LoadingThe button is disabled while loading, so focus is dropped. If the user activated it from the keyboard, move focus to the result or a status region when the request resolves.
Icon-only buttonsNeeds your attention. An icon-only button has no accessible name — the icon glyph isn't text. Always pass attrs => ['aria-label' => 'Delete workshop']. The component does not add one for you.
ContrastVariants pair tested token foreground and background. The ghost variant's resting text is --text-default, not a muted grey, for exactly this reason.
<?= ws_icon_button('delete', [
    'variant' => 'danger',
    'attrs'   => ['aria-label' => 'Delete workshop'],
]) ?>

Tokens

TokenUsed for
--button-bg-primary / --button-text-primaryPrimary fill and label
--button-shadow-primary-hoverPrimary hover lift
--phase-color / --phase-color-dark / --phase-color-very-lightSecondary variant theming, set by an ancestor
--phase-learn / --phase-learn-dark / --phase-learn-very-lightFallback when no phase colour is set
--mainsite-primaryBrand accent
--color-error / --color-error-darkDanger variant
--gray-50 to --gray-500Ghost hover fill, borders, and disabled treatment
--bg-surfaceSecondary and ghost backgrounds
--text-default / --text-inverseLabel colours
--text-meta / --text-small / --text-uiLabel type scale across sizes
--font-body / --font-semibold / --tracking-wideFace, weight, and letter-spacing
--transition-fastHover and active transitions

CSS Classes

ClassPurpose
.ws-btnBase styles, on the <button> or <a>
.ws-btn--primaryPrimary variant
.ws-btn--secondarySecondary variant (phase-themed)
.ws-btn--ghostGhost variant
.ws-btn--dangerDanger variant
.ws-btn--sm / --md / --lgSize modifiers
.ws-btn--fullFull width (fullWidth: true)
.ws-btn--half50% width for narrow card columns — no helper option, pass via class
.ws-btn--loadingLoading state
.ws-btn--icon-onlyIcon-only button with no label
.ws-btn__iconIcon wrapper (either side)
.ws-btn__textLabel wrapper
.ws-btn__spinnerLoading spinner

Files

FilePurpose
includes/components/helpers.phpws_button() and the ws_icon_button() shorthand
includes/components/button.phpTemplate — tag selection and class assembly
includes/components/components.cssStyles (.ws-btn rules)