Files

7.2 KiB
Raw Permalink Blame History

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 ::before scaling from bottom 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: 1 is a ratio, not 1px. button.md reversed this on 2026-08-17; converting it is the documented trap.
  • on-inverse is 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.cjs safelists /^bds-btn/ and /^bds-icon/; without them the production build silently strips the component.
  • :where(html.dark) in §4 is load-bearing. A bare html.dark selector outranks the state swaps and leaves disabled buttons with their resting fill in dark mode only.