Actions
Components that trigger operations or navigate the user. Buttons are for in-page actions; links are for navigation. Both consume Tier-3 tokens so the full action palette can be rethemed in one place.
Buttons
Four variants cover the full intent spectrum from primary confirmation to destructive operations. All variants share .btn as a base class and consume --btn-* Tier-3 tokens.
Sizes
Size modifiers adjust height and padding only. Font weight and variant colours are unaffected. Default fits most layouts; use .btn-sm for dense UIs and .btn-lg for hero CTAs.
States
Loading
Add .btn-loading to suppress pointer events and signal a busy cursor while an async operation runs. Place a .spinner .spinner-sm element inside the button; update aria-label on the button to describe what is happening.
Full reference — Buttons
Variants
Apply .btn first, then exactly one variant modifier. Use the variant that matches the intent of the action — not its visual weight preference.
| Variant | Class | Use |
|---|---|---|
| Primary | .btn-p | The single most important action on a page or in a dialog. Use once per view. |
| Secondary | .btn-s | Supporting actions alongside a primary button — cancel, back, view details. |
| Ghost | .btn-g | Low-emphasis actions in toolbars, cards, or dense layouts where ink weight matters. |
| Destructive | .btn-d | Irreversible operations: delete, remove, revoke. Pair with a confirmation dialog. |
Size tokens
| Size | Class | Height | Padding | Font size | Font token |
|---|---|---|---|---|---|
| Small | .btn-sm | 32px | 0 12px | 11px | --btn-sm-font-size |
| Default | — | 40px | 0 16px | 12px | --btn-font-size |
| Large | .btn-lg | 48px | 0 20px | 14px | --btn-lg-font-size |
Disabled state
Hover, active, and focus states are driven by --btn-* token values. Focus uses the global :focus-visible outline from base.css. Disabled buttons reduce opacity via --btn-disabled-opacity and suppress pointer events.
For <a> elements acting as disabled buttons, use aria-disabled="true" and tabindex="-1" with inline opacity and pointer-events: none — the disabled attribute has no effect on anchors.
<button class="btn btn-p" disabled>Can't proceed</button>
<!-- anchor disabled state -->
<a class="btn btn-p" aria-disabled="true" tabindex="-1"
style="opacity: var(--btn-disabled-opacity); pointer-events: none;">
Can't proceed
</a>Loading state
<button class="btn btn-p btn-loading" aria-label="Saving">
<span class="spinner spinner-sm" aria-hidden="true"></span>
Saving…
</button>The spinner is aria-hidden because the button's own label already describes the state. Toggle .btn-loading via JavaScript when the async operation starts and ends. Do not use the HTML disabled attribute for loading — it removes the button from the tab order and prevents focus restoration after the operation completes.
Anatomy
<!-- base class + variant modifier -->
<button class="btn btn-p">Label</button>
<!-- with size modifier -->
<button class="btn btn-s btn-sm">Label</button>
<!-- with icon (flexbox gap: 6px) -->
<button class="btn btn-g">
<i class="ti ti-download"></i>
Download
</button>
<!-- link element acting as a button -->
<a href="/docs" class="btn btn-p">Read the docs</a>Token reference
| Token | Default | Purpose |
|---|---|---|
--btn-radius | var(--radius-md) | Border radius for all button variants |
--btn-disabled-opacity | 0.5 | Opacity applied to disabled buttons |
| Primary | ||
--btn-p-bg | var(--color-accent) | Primary background |
--btn-p-text | var(--color-accent-text) | Primary text colour |
--btn-p-hover-bg | var(--color-accent-hover) | Primary hover background |
--btn-p-active-bg | var(--color-accent-active) | Primary pressed background |
--btn-p-hover-text | --btn-p-text (dark mode: --bm2-birch) | Primary hover text colour — flips to birch in dark mode, where void text on the fern hover background would fail AA |
--btn-p-active-text | --btn-p-text (dark mode: --bm2-birch) | Primary pressed text colour — same dark-mode swap as hover |
| Secondary | ||
--btn-s-bg | var(--color-bg-inset) | Secondary background |
--btn-s-text | var(--color-text) | Secondary text colour |
--btn-s-hover-bg | var(--color-bg-interactive-hover) | Secondary hover background |
| Ghost | ||
--btn-g-bg | transparent | Ghost background |
--btn-g-border | var(--color-border) | Ghost border colour |
--btn-g-hover-bg | var(--color-bg-interactive-hover) | Ghost hover background |
| Destructive | ||
--btn-d-bg | var(--color-error) | Destructive background |
--btn-d-text | var(--color-on-error) | Destructive text colour |
--btn-d-hover-bg | var(--in0-void) | Destructive hover background (theme-invariant) |
Overriding tokens
/* Retheme the primary button for a promotional section */
.promo-section {
--btn-p-bg: #7C3AED;
--btn-p-hover-bg: #6D28D9;
--btn-p-text: #fff;
}
/* Pill-shaped buttons in a card */
.card {
--btn-radius: var(--radius-full);
}Links
Naked <a> elements inherit --link-color,--link-hover-color, and --link-visited-color fromtokens/base.css. Override these tokens to retheme inline links without touching --color-accent. Any <a> also accepts.btn classes to render as a button.
Inline text link — uses --link-color and underlines on hover.Visited links use --link-visited-color.
Full reference — Links
Naked links
Base link styles are applied globally via tokens/base.css. No class needed — just use <a href="…">.
<p>See the <a href="/docs">full documentation</a> for details.</p>Link buttons
Any <a> element accepts the .btn classes. The base styles set text-decoration: none so links render identically to<button> elements. Use <button> for in-page actions and <a> for navigation.
<a href="/docs" class="btn btn-p">Read the docs</a>
<a href="/cancel" class="btn btn-s">Cancel</a>Token reference
| Token | Default | Purpose |
|---|---|---|
--link-color | var(--color-accent) | Naked link colour |
--link-hover-color | var(--color-accent-hover) | Naked link hover colour |
--link-visited-color | var(--color-accent) | Naked link visited colour |
Overriding tokens
/* Retheme inline links in a dark editorial section */
.editorial {
--link-color: var(--fo0-glade);
--link-hover-color: var(--fo1-fern);
}Tooltip
Add data-tooltip="…" to any element to attach a tooltip bubble. CSS-only — no script required. Shows on :hover and :focus-visible so keyboard users see it without JavaScript.
Full reference — Tooltip
Placement
Default placement is above the trigger. Add data-tooltip-placement="bottom" when the trigger sits near the top of the viewport and a top tooltip would overflow.
| Attribute value | Placement |
|---|---|
| (absent) | Above the trigger (default) |
"bottom" | Below the trigger |
Anatomy
Works on any element that supports pseudo-elements. Does not work on replaced/void elements: <input>, <img>, <select>, <textarea>. Wrap them in a <span data-tooltip="…"> instead.
<!-- Icon button with tooltip (hover + keyboard focus) -->
<button data-tooltip="Delete item" aria-label="Delete item">
<svg aria-hidden="true">…</svg>
</button>
<!-- Below placement -->
<button data-tooltip="More options"
data-tooltip-placement="bottom"
aria-label="More options">
⋯
</button>
<!-- Replaced element: wrap in a span -->
<span data-tooltip="Profile photo" style="display:inline-block">
<img src="avatar.jpg" alt="Profile photo">
</span>
<!-- Abbreviation (hover only — not focusable) -->
<abbr data-tooltip="Cascading Style Sheets"
title="Cascading Style Sheets">CSS</abbr>Accessibility
content: attr(data-tooltip) in CSS pseudo-elements is not exposed to the accessibility tree. Always pair data-tooltip with aria-label(interactive elements) or title (non-interactive elements such as<abbr>) carrying the same text.
Tooltips respond to :focus-visible in CSS, so keyboard users see the bubble on focus without JavaScript. Non-focusable elements are hover-only — addtabindex="0" if keyboard access is needed for that element.
Touch devices: :hover is unreliable on mobile. If the tooltip text is essential (not supplementary), surface it in visible UI rather than relying on a tooltip.
Token reference
| Token | Light default | Dark default | Purpose |
|---|---|---|---|
--tooltip-bg | var(--in0-void) | var(--bm1-mist) | Bubble background — theme-inverse for guaranteed contrast |
--tooltip-text | var(--bm2-birch) | var(--in0-void) | Bubble text colour |
--tooltip-max-width | 220px | Maximum bubble width before text wraps | |
--tooltip-radius | var(--radius-sm) | Bubble corner radius | |
--tooltip-padding | 4px 10px | Internal bubble padding | |
--tooltip-font-size | 0.75rem | Bubble font size (12px) | |
Overriding tokens
/* Custom tooltip colour in a dark promo section */
.promo {
--tooltip-bg: var(--fo2-forest);
--tooltip-text: var(--bm2-birch);
}Known limitation — viewport edges
The bubble is centred on the trigger midpoint via left: 50%; transform: translateX(-50%). On triggers near the left or right edge of the viewport the bubble may overflow. For fully collision-aware positioning use a JavaScript positioning library (e.g. Floating UI).