Actions
Components that trigger actions or move users to another page.
Actions are where a design system is judged, because they are where a user commits. In Senco one of these components sends money, and the rules below are shaped by the fact that the action cannot be taken back.
Components
| Component | Purpose | Element |
|---|---|---|
| Button | Trigger an action (submit, cancel, delete, etc.) | <button> |
| Toggle group | Mutually exclusive button group for switching views or modes | <div role="radiogroup"> or role="group" |
| Link | Navigate to another page or section | <a> |
Button or link
The distinction is not visual, it is semantic, and getting it wrong breaks keyboard behavior and screen reader output.
| Use | When | Key |
|---|---|---|
<button> | Something happens — submit, open, delete, toggle | Enter and Space |
<a href> | Somewhere is navigated to — a URL changes | Enter only |
A link styled as a button is still a link. A <div> with an onClick is neither, and loses focus, keyboard activation, and role in one stroke. If a control navigates, it must have an href that works when opened in a new tab.
Button
Anatomy
┌─────────────────────────────────────┐
│ [icon] Label [icon] │ min-height 48px
└─────────────────────────────────────┘
│ │ │
│ │ └── trailing icon (optional) — direction only
│ └── label — verb + noun, sentence case
└── leading icon (optional) — reinforces the action
| Part | Required | Token |
|---|---|---|
| Container | Yes | --ds-radius-md (6px) |
| Label | Yes | --ds-text-sm, --ds-font-semibold |
| Leading icon | No | --ds-size-icon (20px) |
| Trailing icon | No | --ds-size-icon (20px) |
| Min height | Yes | --ds-size-touch (48px) |
Types
button— triggers an action without form submissionsubmit— submits a formreset— resets form values
reset is effectively unused in Senco. A reset button sits next to submit, looks identical, and discards a form a user just spent four minutes filling in. Where a clear action is genuinely needed it is a link-style action placed away from the primary button.
Roles
primary— main action on the pagesecondary— supporting actiondanger— destructive or irreversible action
Variants
| Variant | Background | Text | Border | Use |
|---|---|---|---|---|
| Primary | --ds-primary | --ds-on-accent | none | The one action the screen exists for |
| Secondary | --ds-bg | --ds-heading | --ds-border | Supporting action beside a primary |
| Danger | --ds-red-600 | --ds-on-error | none | Destructive and irreversible |
| Link | transparent | --ds-link | none | Tertiary action — see Link below |
One primary per screen. Two primaries is not a stronger call to action, it is the absence of one.
Sizes
| Size | Min height | Radius | Padding | Token set |
|---|---|---|---|---|
| Default | 48px (--ds-size-touch) | --ds-radius-md (6px) | --ds-space-5 inline | none — composes semantic + state |
| Pill | 48px (--ds-pillbtn-h) | --ds-radius-full | --ds-pillbtn-px (24px) | --ds-pillbtn-* |
| Action tile | 72px (--ds-actionbtn-min-h) | --ds-radius-full | --ds-space-4 | --ds-actionbtn-* |
All three use --ds-text-sm / 600 for the label.
These are three components sharing a name, not three sizes of one component. The default button is a 6px rectangle; the pill is a 48px capsule used in the mobile action bar; the action tile is a 72px card with a 40px circular icon above its label, used only in the dashboard quick-action row. --ds-actionbtn-* belongs to the tile. Applying its radius-full to the default button is the mistake this table exists to prevent.
There is no small button. A 36px control fails the 48px touch standard, and the place it would be used — a dense row action — is served by an icon button with an expanded hit area instead.
API
type ButtonVariant = "primary" | "secondary" | "danger" | "link";
type ButtonSize = "default" | "pill" | "tile";
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "disabled"> {
variant?: ButtonVariant; // default: "secondary"
size?: ButtonSize; // default: "default"
type?: "button" | "submit"; // default: "button" — "reset" is not offered
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
fullWidth?: boolean; // default: false — true in the mobile action bar
loading?: boolean; // default: false
loadingLabel?: string; // default: "Working" — announced, not rendered
disabled?: boolean; // rendered as aria-disabled, never the disabled attribute
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode; // the label; required, never icon-only
}
| Prop | Contract |
|---|---|
variant | There is one variant axis. "Role" elsewhere in this page is the same axis under another name |
size | "tile" selects the action-tile component described in Tokens; it renders icon above label |
disabled | Applies aria-disabled="true" and suppresses activation. The native disabled attribute is never set, so the control stays focusable and describable |
loading | Controlled by the caller. Implies disabled behaviour, keeps the label, and replaces leadingIcon with a spinner. The button releases when loading returns to false — the caller owns the request and its 10s timeout, and sets loading back on success, failure, or timeout |
children | Icon-only buttons are not this component. An icon-only control is an Icon button with its own aria-label |
An aria-disabled button remains in the tab order and still fires onClick, still activates on Enter and Space, and still submits its form. The component is responsible for suppressing all four. This is the mechanism behind the aria-disabled rule stated across the system, and it is the part that gets missed.
States
The default button has no shadow in any state. Shadow belongs to the action tile, which is a different component with its own tokens.
| State | Primary | Secondary | Danger | Link |
|---|---|---|---|---|
| Default | bg --ds-primary, text --ds-on-accent | bg --ds-bg, text --ds-heading, 1px --ds-border | bg --ds-red-600, text --ds-on-error | text --ds-link, underlined |
| Hover | bg --ds-primary-hover | bg --ds-surface | bg --ds-red-700 | text --ds-primary-hover |
| Active | Same as hover | Same as hover | Same as hover | Same as hover |
| Focus-visible | 3px solid --ds-focus outline, 2px offset | same | same | same |
| Disabled | bg --ds-disabled-bg, text --ds-disabled-text | same | same | text --ds-gray-400, no underline |
| Loading | LoaderCircle at --ds-size-icon (20px) in the leading slot, currentColor, rotating 1s linear; label stays; variant colours stay — loading does not use the disabled appearance | same | same | Not offered |
Transitions animate background-color, color, and border-color over --ds-duration-fast with --ds-ease-default. The danger variant's --ds-red-600 / --ds-red-700 are Base references; see Known gaps.
Block padding is --ds-space-3 on the default and pill sizes. The 48px minimum height governs, and a label that wraps to two lines grows the button by its line height. Inline padding is --ds-space-5 on default and --ds-pillbtn-px on pill.
Pill and tile sizes each support two variants:
| Size | Variant | Default | Hover |
|---|---|---|---|
| Pill | Primary | bg --ds-pillbtn-primary-bg, text --ds-pillbtn-primary-text | bg --ds-primary-hover |
| Pill | Secondary | bg --ds-pillbtn-secondary-bg, text --ds-pillbtn-secondary-text, 1px --ds-pillbtn-secondary-border | bg --ds-primary-light |
| Tile | Primary | --ds-actionbtn-primary-bg, -text, -shadow | --ds-actionbtn-primary-bg-hover, -shadow-hover |
| Tile | Secondary | --ds-actionbtn-secondary-bg, -text, -border, -shadow | --ds-actionbtn-secondary-bg-hover, -border-hover, -shadow-hover |
Danger and link are not offered at pill or tile size. A destructive action is never the prominent pill in the action bar or a dashboard tile.
While loading is true the button renders a visually hidden role="status" sibling containing loadingLabel, so the start of loading is announced once. The end of loading is not announced by the button; whatever shows the result announces it.
Loading keeps the label. Replacing "Send 250 USD" with a bare spinner removes the only confirmation of what is being sent at the moment the user most wants it.
Behavior
- A button in a loading state is
aria-disabledand ignores further clicks, but stays focusable. - Width is content-driven except in the mobile action bar, where the primary button is full-width.
- Labels never truncate. A long translated label wraps to two lines and the button grows, because a clipped action label is unreadable in exactly the languages that need it most.
- Double submission is prevented by the caller. The press handler sets
loadingtotruesynchronously, before anyawait; whileloadingis true the button suppresses activation, so a second tap in the same frame does nothing. The caller setsloadingback tofalseon response or on its 10s timeout.
Content
- Verb + noun, sentence case: "Send money", "Add recipient", "Delete contact"
- Name the object, not the mechanism: "Send 250 USD", not "Submit transfer request"
- Avoid "OK", "Yes", "Submit", "Confirm" alone — in a dialog the button should restate the action so it reads correctly out of context
- Maximum four words
| Do | Don't |
|---|---|
| "Send 250.00 USD" | "Confirm" |
| "Delete recipient" | "Yes" |
| "Save changes" | "Submit" |
| "Try again" | "Retry operation" |
Accessibility
- Minimum touch target: 48×48px
- Use
<button>, never a styled<div>or<a>withouthref - Icon-only buttons require
aria-label - Disabled buttons use
aria-disabled, not thedisabledattribute, so they remain focusable and can explain themselves viaaria-describedby - Focus indicator visible on keyboard navigation, using
:focus-visible - Danger buttons require confirmation for destructive actions
- A loading button announces its state change via an
aria-live="polite"region, not by changing its accessible name mid-action
| Key | Action |
|---|---|
Tab | Move focus to the button |
Enter | Activate |
Space | Activate |
Tokens
Default size: --ds-primary · --ds-primary-hover · --ds-on-accent · --ds-bg · --ds-surface · --ds-heading · --ds-border · --ds-red-600 · --ds-red-700 · --ds-on-error · --ds-link · --ds-focus · --ds-focus-ring-width · --ds-focus-ring-offset · --ds-disabled-bg · --ds-disabled-text · --ds-radius-md · --ds-size-touch. Pill size adds --ds-pillbtn-*; tile size uses --ds-actionbtn-* instead.
Known gaps
- No icon-only button variant is specified, though the pattern is used in the app bar.
- Button group (multiple related buttons in a row) is undocumented.
- 1Container
- 2Label text
- 3Leading icon
- 4Focus ring
- 5Touch target area
Toggle group
Mutually exclusive set of toggle buttons — only one (or none) can be active at a time.
- Used for view switching (List / Grid), time period selection (Day / Week / Month)
- Single-select by default; can support multi-select
- Supports icon-only and text variants
- Active item is visually distinct from inactive items
- Keyboard navigable with arrow keys within the group
Anatomy
┌──────────┬──────────┬──────────┐
│ Money │ Crypto │ │ one Tab stop
└──────────┴──────────┴──────────┘
▲
└── active: --ds-primary-light bg, --ds-primary text
Variants
| Variant | Selection | Use |
|---|---|---|
| Single | Exactly one active | Money / Crypto rail selection |
| Single, deselectable | Zero or one active | Optional filters |
| Multi | Any number active | Transaction type filters |
API
interface ToggleGroupProps<T extends string = string> {
label: string; // accessible name for the group — required
items: Array<{
value: T;
label: string;
icon?: React.ReactNode;
disabled?: boolean;
}>;
selectionMode?: "single" | "single-deselectable" | "multi"; // default: "single"
value: T | T[] | null; // T for single, T[] for multi, null when deselected
onValueChange: (value: T | T[] | null) => void;
fullWidth?: boolean; // default: true on viewports below --ds-bp-mobile
}
selectionMode is the variant dimension and it changes the ARIA contract: single renders role="radiogroup" with aria-checked, multi renders role="group" with aria-pressed on each item. A caller switching modes changes the semantics, not only the behaviour.
States
| State | Visual |
|---|---|
| Inactive | --ds-bg, --ds-muted label |
| Active | --ds-primary-light fill, --ds-primary label, weight 600 |
| Hover | --ds-surface fill on inactive items only |
| Focus-visible | 3px solid --ds-focus outline on the focused item, 2px offset — not on the group |
| Disabled item | --ds-disabled-text, skipped by arrow keys |
Active state uses fill plus weight, not fill alone. Under a deuteranopia simulation a blue-tinted fill against white is a faint value shift; the weight change survives it.
Behavior
- Selecting an item applies immediately. A toggle group is not a form field awaiting submit.
- The group never has zero items visible; an empty group is a bug, not an empty state.
- On mobile the group is full-width with equal-width items. Content-width items produce a ragged right edge that reads as broken alignment.
Content
Item labels are one or two words and parallel in form — "Money" and "Crypto", not "Money" and "Send with crypto".
Accessibility
- The whole group is one Tab stop. Arrow keys move within it.
- Single-select uses
role="radiogroup"withrole="radio"children andaria-checked. - Multi-select uses
role="group"witharia-pressedon each<button>. - The group has an accessible name via
aria-labeloraria-labelledby. - Icon-only items require
aria-labelon each item.
| Key | Action |
|---|---|
Tab | Move focus into / out of the group |
← → | Move between items (wraps) |
Home / End | First / last item |
Space | Toggle the focused item (multi-select) |
In single-select, arrow keys both move focus and change selection — this is the standard radio group behavior and it is what screen reader users expect.
Tokens
--ds-primary-light · --ds-primary · --ds-surface · --ds-border · --ds-radius-md · --ds-focus-ring
Known gaps
- Multi-select is documented but not implemented in any shipped screen.
- Overflow behavior for more than four items is unspecified.
Link
Inline text actions used for secondary navigation and contextual operations. All link buttons use <Button variant="link">.
Usage
- Use for secondary actions below primary buttons (Back to home, Edit payment method)
- Use destructive variant for irreversible actions (Delete contact, Remove method, Cancel transfer)
- Icon placement is always inline-start (left of text)
- Never use for primary page actions — use
<Button>instead
Anatomy
Optional icon inline-start, then the label. Icons never sit inline-end on a link — that position is reserved for the external mark.
Variants
| Variant | Class | Color | Example |
|---|---|---|---|
| Default | base | var(--ds-link) | Forgot password?, Back to home |
| With icon | base | var(--ds-link) | + Add payment method |
| Destructive | .link-destructive | var(--ds-red-600) | Delete contact, Remove method |
Style
| Property | Inline link | Standalone link |
|---|---|---|
| Font size | var(--ds-text-sm) — 14px | var(--ds-text-sm) — 14px |
| Font weight | var(--ds-font-semibold) — 600 | var(--ds-font-semibold) — 600 |
| Default state | Underline, offset 2px | Underline, offset 2px |
| Hover | Color var(--ds-primary-hover) | Color var(--ds-primary-hover) |
| Focus-visible | 3px solid var(--ds-focus) outline, 2px offset | 3px solid var(--ds-focus) outline, 2px offset |
| Disabled | var(--ds-gray-400), no underline | var(--ds-gray-400), no underline |
| Height | auto — flows with the text | 48px min-height (h-12) |
| Padding | 0 inline, 0 block | 0 inline, block padding to reach 48px |
The two rows that differ are the whole distinction. An inline link sits inside a sentence; giving it a 48px box would break the line height of the paragraph around it, and its hit area is the text. A standalone link sits on its own line as an action, and takes the same 48px target as every other control.
An earlier version of this table gave one set of values — height: auto, padding: 0 — while the accessibility section below required a 48px target from padding. Those cannot both hold, and the resolution is that they describe different links.
Inline links are always underlined
Inside a paragraph, a link is underlined. Color alone is not a sufficient indicator — it fails SC 1.4.1 for colorblind readers and it fails anyone reading in bright sunlight on a low-contrast screen. Standalone link-style actions outside a text block may drop the underline, because position and spacing already identify them as actions.
API
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string; // required — a control without a destination is a Button
placement?: "inline" | "standalone"; // default: "inline"
tone?: "default" | "destructive"; // default: "default"
icon?: React.ReactNode; // rendered inline-start only
external?: boolean; // default: false — adds the external mark, target, rel, and "opens in a new tab"
children: React.ReactNode;
}
placement is what resolves the height contract: inline flows with the text at height: auto, standalone takes the 48px minimum target. It is a required decision, not a styling preference.
A link-styled control that performs an action rather than navigating is <Button variant="link">, which takes ButtonProps. The two are not interchangeable — Space activates one and not the other.
States
| State | Visual |
|---|---|
| Default | --ds-link, underlined, 2px offset |
| Hover | --ds-primary-hover, underline persists |
| Focus-visible | 3px solid --ds-focus outline, 2px offset; underline persists |
| Visited | No distinct style — an in-app action has no "visited" meaning |
| Disabled | --ds-gray-400, no underline, aria-disabled |
Behavior
- Standalone links get a 48px hit area via padding even though the text itself is 21px tall.
- External links carry an external-link icon and open in a new tab with
rel="noopener"; the accessible name states "opens in a new tab". - Destructive links still require a confirmation step. The lighter visual weight does not make the action lighter.
Content
- Link text describes the action or destination, standalone
- Never "click here", "read more", "learn more"
- Screen reader users navigate by a list of links; a page with four "Learn more" links is a page with four unusable links
| Do | Don't |
|---|---|
| "Edit payment method" | "Edit" |
| "See all transfers" | "More" |
| "How fees are calculated" | "Learn more" |
| "Delete this recipient" | "Remove" |
Accessibility
- Link text must describe the action or destination
- Avoid generic text ("click here", "read more")
- Destructive links must use both color and text to convey intent (not color alone)
- Minimum touch target: 48px height when standalone (use
h-12class) - Focus indicator visible on keyboard navigation
- A link that performs an action rather than navigating is the wrong element — use a link-variant
<button>
| Key | Action |
|---|---|
Tab | Move focus to the link |
Enter | Activate |
Space does not activate a link. If a control must respond to Space, it is a button.
Tokens
--ds-link · --ds-primary-hover · --ds-red-600 · --ds-gray-400 · --ds-text-sm · --ds-font-semibold · --ds-focus · --ds-focus-ring-width · --ds-focus-ring-offset
Known gaps
.link-destructiveis documented as a class; no corresponding--ds-link-destructivetoken exists.- External link treatment is specified here but not implemented as a component prop.
View your transaction history or download a statement.