Button
Triggers an action. The most-used interactive component on the platform, so its rules are the strictest.
When to Use
primary per view; everything else is secondary or ghost.
Variants
Style
Four variants, in descending order of emphasis. danger is not more important than primary — it's for destructive actions only.
| Variant | Use for |
|---|---|
primary | The single most important action in the view |
secondary | Alternative actions alongside the primary. Inherits --phase-color. |
ghost | Low-emphasis and repeated actions — toolbar controls, row actions |
danger | Destructive, 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>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
| State | Behavior |
|---|---|
| Hover | Per-variant colour shift, gated on :not(:disabled). Primary also lifts with --button-shadow-primary-hover. |
| Active | Presses down via .ws-btn:active:not(:disabled). |
| Focus | Browser default focus ring — the platform's one sanctioned non-red accent. Don't remove it without providing a replacement. |
| Disabled | Sets the native disabled attribute, so the button is unclickable and skipped by Tab. Hover and active rules are suppressed. |
| Loading | Adds .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. |
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
| Option | Type | Default | Purpose |
|---|---|---|---|
$text | string | — | Positional label. Lead with a verb: "Save workshop", not "OK". |
variant | string | 'primary' | primary | secondary | ghost | danger |
size | string | 'md' | sm | md | lg |
icon | string | null | Material icon name |
iconPosition | string | 'left' | left | right |
href | string | null | Renders an <a> instead of a <button> |
type | string | 'button' | button | submit | reset. Set submit explicitly inside forms. |
disabled | bool | false | Native disabled state. Ignored when href is set. |
loading | bool | false | Shows the spinner and disables the button |
fullWidth | bool | false | Stretch to the container width |
id | string | null | Element ID |
class | string | '' | Additional CSS classes |
attrs | array | [] | 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
| Concern | Behavior |
|---|---|
| Semantics | Renders a real <button>, or an <a> when href is set. Both are natively focusable and correctly announced — no ARIA role is added or needed. |
| Keyboard | Native: Tab to reach, Enter or Space to activate a button, Enter for a link. |
| Focus | Browser default focus ring. It's the sky-blue accent that survived Red Unification precisely because focus visibility outranks brand consistency. |
| Disabled | The 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. |
| Loading | The 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 buttons | Needs 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. |
| Contrast | Variants 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
| Token | Used for |
|---|---|
--button-bg-primary / --button-text-primary | Primary fill and label |
--button-shadow-primary-hover | Primary hover lift |
--phase-color / --phase-color-dark / --phase-color-very-light | Secondary variant theming, set by an ancestor |
--phase-learn / --phase-learn-dark / --phase-learn-very-light | Fallback when no phase colour is set |
--mainsite-primary | Brand accent |
--color-error / --color-error-dark | Danger variant |
--gray-50 to --gray-500 | Ghost hover fill, borders, and disabled treatment |
--bg-surface | Secondary and ghost backgrounds |
--text-default / --text-inverse | Label colours |
--text-meta / --text-small / --text-ui | Label type scale across sizes |
--font-body / --font-semibold / --tracking-wide | Face, weight, and letter-spacing |
--transition-fast | Hover and active transitions |
CSS Classes
| Class | Purpose |
|---|---|
.ws-btn | Base styles, on the <button> or <a> |
.ws-btn--primary | Primary variant |
.ws-btn--secondary | Secondary variant (phase-themed) |
.ws-btn--ghost | Ghost variant |
.ws-btn--danger | Danger variant |
.ws-btn--sm / --md / --lg | Size modifiers |
.ws-btn--full | Full width (fullWidth: true) |
.ws-btn--half | 50% width for narrow card columns — no helper option, pass via class |
.ws-btn--loading | Loading state |
.ws-btn--icon-only | Icon-only button with no label |
.ws-btn__icon | Icon wrapper (either side) |
.ws-btn__text | Label wrapper |
.ws-btn__spinner | Loading spinner |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_button() and the ws_icon_button() shorthand |
includes/components/button.php | Template — tag selection and class assembly |
includes/components/components.css | Styles (.ws-btn rules) |