7.2 KiB
Button
Built from the Figma-derived specification. Appearance is three axes —
intention × context × emphasis — bound straight to the token groups.
Geometry never varies: padding, the 40px minimums and the 1px border are
identical on every combination and every state.
Live at /button-demo — every combination, every state, on all three surfaces.
Provenance
Every value comes from github.com/samiamdesigns/pd-xrpl-developer-docs:
components/button.md (the axes and the reasoning), components/button.json
(values resolved per mode), components/button-examples.md (the acceptance
checklist), plus accessibility/focus-indicators.md,
implementation/icons-that-inherit-colour.md and
implementation/font-stacks.md.
The colour matrix in Button.scss §2 was generated rather than transcribed and
verified back against button.json: all 270 values, both modes, no RGB
mismatch.
API
<Button>Get started</Button>
<Button intention="neutral" emphasis="standard">Learn more</Button>
<Button context="on-saturated">On a green block</Button>
<Button href="/docs" target="_blank">Read the docs</Button>
<Button iconEnd={<XrplArrowInternalLinkIcon />}>Read more</Button>
<Button loading>Submitting…</Button>
| Prop | Values | Default |
|---|---|---|
intention |
brand · neutral |
brand |
context |
on-theme · on-inverse · on-saturated |
on-theme |
emphasis |
strong · standard · subtle |
strong |
loading |
boolean — aria-busy, activation suppressed, indicator shown |
false |
inactive |
boolean — aria-disabled, stays in the tab order |
false |
disabled |
boolean — native disabled, leaves the tab order |
false |
href / target |
renders an <a> |
— |
iconStart / iconEnd |
decorative, aria-hidden — either slot omitted renders no icon and reserves no space |
— |
context is the axis to get right
Each context is measured against a different backdrop, so choosing the wrong one
is the likeliest way to produce a button that looks plausible and fails
contrast. It also drives the focus-ring colour, and nothing will tell you if
that is wrong — a button that takes context="on-inverse" for its paint and
leaves the ring at its default renders perfectly.
neutral + on-saturated does not compile
There are no tokens for it. The props are a discriminated union, so that combination is a type error rather than a runtime surprise.
Two strong buttons must not share a container
Emphasis is what says which action is primary. No checker catches this — a row
of identical strong buttons is contrast-clean and still wrong.
States
Six states, three appearances. hover/pressed/loading resolve
byte-identically and inactive == rest, in all 15 combinations, in both
modes.
| Appearance | States |
|---|---|
| Resting | rest, inactive |
| Engaged | hover, pressed, loading |
| Disabled | disabled — its own group |
They stay separate code paths, because what distinguishes them is behaviour:
| ARIA | tab order | activates | |
|---|---|---|---|
loading |
aria-busy |
in | no |
inactive |
aria-disabled |
in | no |
disabled |
native disabled |
out | no |
Collapsing inactive into disabled removes it from the tab order, and a
screen-reader user can then no longer find it.
disabled is chosen by context alone — not by intention, not by emphasis.
Emphasis only selects the shape: strong keeps fill and border, standard
drops the fill, subtle drops both.
Anchor paint
href renders an <a>, so every bare a rule on the site applies to it —
Bootstrap's, the theme's, and any container styling its own anchors.
Button.scss §5 out-specifies them with a doubled class,
a.bds-btn.bds-btn:link and the other four link pseudo-classes.
Do not collapse those into :is(). PurgeCSS drops a rule when the argument
to :is() or :where() is a pseudo-class list or a :not() chain, so the tidy
form compiles, works in realm develop, and vanishes from the production bundle
— leaving anchor buttons painted as links. A plain selector argument is fine,
which is why :where(html.dark) in §4 survives. §5 is also deliberately
unlayered, since everything above it sits in @layer bds-btn.*.
Not from the token set
- The background rise — a
::beforescaling frombottom center. The spec says hover swaps the whole triplet; the rise is only how the fill arrives. - The animated arrow, with the motion inside the icon's own viewBox so the button's geometry never changes on hover.
- Motion values: 150ms,
cubic-bezier(0.98, 0.12, 0.12, 0.98).
Deliberate deviations from the spec
| Deviation | Why |
|---|---|
intention is optional |
button.md's printed union marks it required, which would reject <Button>Get started</Button> — the zero-prop default its own examples show. button.json records "default": "brand". Reported upstream. |
Loader glyph is LoaderIcon |
The spec's glyph is an explicit placeholder and asks us to pick one and report back. This is the choice to report. |
| The loader slows under reduced motion rather than stopping | Freezing it removes the only signal that anything is happening. aria-busy covers assistive technology; nothing covers a sighted user watching a motionless spinner. |
Alpha comes from _colors.scss, not button.json |
The JSON rounds alpha to 2dp; _colors.scss carries the generated 8-digit Radix value the tokens are built from. 57 of 270 values differ by up to 1.2/255 of alpha. No RGB channel differs anywhere. |
Where the colours come from
Every colour in the palette is a variable from styles/_colors.scss — not
one hex literal in the map. _colors.scss already carries the generated Radix
scales the tokens are built from, so binding the variables is shorter and truer
than copying numbers, and a palette change propagates on its own.
The pairs double as documentation: ($sage-12, $sage-dark-12) reads as "step 12
of the neutral scale, per mode", and the on-inverse groups visibly swap them.
Three exceptions, commented in the file: the focus ring's #111111 and
#000000 come from mode-color.focus-indicator.*, a token family with no
counterpart in any scale the site carries. White binds $white.
To change a colour, edit the map in §2 — one @each loop emits all 30 rules.
To re-derive it, re-resolve button.json; if button.md and button.json
disagree, neither wins, re-resolve from the token set.
Traps
line-height: 1is a ratio, not1px.button.mdreversed this on 2026-08-17; converting it is the documented trap.on-inverseis not a mode-swap of its base group. 104 of 108 values survive that shortcut and 4 border values do not, so it is emitted literally.- PurgeCSS. The group/emphasis/context classes are composed from props at
runtime and emitted from a Sass loop, so they appear as literals nowhere.
postcss.config.cjssafelists/^bds-btn/and/^bds-icon/; without them the production build silently strips the component. :where(html.dark)in §4 is load-bearing. A barehtml.darkselector outranks the state swaps and leaves disabled buttons with their resting fill in dark mode only.