# 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 ```tsx ``` | 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 `` | — | | `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 ``, 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 `` — 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.