Skip to main content
ENKO

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

ComponentPurposeElement
ButtonTrigger an action (submit, cancel, delete, etc.)<button>
Toggle groupMutually exclusive button group for switching views or modes<div role="radiogroup"> or role="group"
LinkNavigate 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.

UseWhenKey
<button>Something happens — submit, open, delete, toggleEnter and Space
<a href>Somewhere is navigated to — a URL changesEnter 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
PartRequiredToken
ContainerYes--ds-radius-md (6px)
LabelYes--ds-text-sm, --ds-font-semibold
Leading iconNo--ds-size-icon (20px)
Trailing iconNo--ds-size-icon (20px)
Min heightYes--ds-size-touch (48px)

Types

  • button — triggers an action without form submission
  • submit — submits a form
  • reset — 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 page
  • secondary — supporting action
  • danger — destructive or irreversible action

Variants

VariantBackgroundTextBorderUse
Primary--ds-primary--ds-on-accentnoneThe one action the screen exists for
Secondary--ds-bg--ds-heading--ds-borderSupporting action beside a primary
Danger--ds-red-600--ds-on-errornoneDestructive and irreversible
Linktransparent--ds-linknoneTertiary action — see Link below

One primary per screen. Two primaries is not a stronger call to action, it is the absence of one.

Sizes

SizeMin heightRadiusPaddingToken set
Default48px (--ds-size-touch)--ds-radius-md (6px)--ds-space-5 inlinenone — composes semantic + state
Pill48px (--ds-pillbtn-h)--ds-radius-full--ds-pillbtn-px (24px)--ds-pillbtn-*
Action tile72px (--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
}
PropContract
variantThere 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
disabledApplies aria-disabled="true" and suppresses activation. The native disabled attribute is never set, so the control stays focusable and describable
loadingControlled 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
childrenIcon-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.

StatePrimarySecondaryDangerLink
Defaultbg --ds-primary, text --ds-on-accentbg --ds-bg, text --ds-heading, 1px --ds-borderbg --ds-red-600, text --ds-on-errortext --ds-link, underlined
Hoverbg --ds-primary-hoverbg --ds-surfacebg --ds-red-700text --ds-primary-hover
ActiveSame as hoverSame as hoverSame as hoverSame as hover
Focus-visible3px solid --ds-focus outline, 2px offsetsamesamesame
Disabledbg --ds-disabled-bg, text --ds-disabled-textsamesametext --ds-gray-400, no underline
LoadingLoaderCircle at --ds-size-icon (20px) in the leading slot, currentColor, rotating 1s linear; label stays; variant colours stay — loading does not use the disabled appearancesamesameNot 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:

SizeVariantDefaultHover
PillPrimarybg --ds-pillbtn-primary-bg, text --ds-pillbtn-primary-textbg --ds-primary-hover
PillSecondarybg --ds-pillbtn-secondary-bg, text --ds-pillbtn-secondary-text, 1px --ds-pillbtn-secondary-borderbg --ds-primary-light
TilePrimary--ds-actionbtn-primary-bg, -text, -shadow--ds-actionbtn-primary-bg-hover, -shadow-hover
TileSecondary--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-disabled and 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 loading to true synchronously, before any await; while loading is true the button suppresses activation, so a second tap in the same frame does nothing. The caller sets loading back to false on 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
DoDon'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> without href
  • Icon-only buttons require aria-label
  • Disabled buttons use aria-disabled, not the disabled attribute, so they remain focusable and can explain themselves via aria-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
KeyAction
TabMove focus to the button
EnterActivate
SpaceActivate

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.
Button anatomy
Send money
1
2
3
4
5
  1. 1Container
  2. 2Label text
  3. 3Leading icon
  4. 4Focus ring
  5. 5Touch target area
Button variants
Button states
Primary
Secondary
Ghost / Tertiary
Destructive
Button specs
Send money
padding: 0 24px
48px
min-width: 80px
Dimensions
Min height48px
Min width80px
Padding H16–24px
Border radius6px
Icon-text gap8px
Typography
Font size16px (1rem)
Font weight600 (SemiBold)
Line height24px
Focus ring
TypeInner border 3px
Offset2px
Touch target48px (AAA)
Send money
ring: 3px
offset: 2px
Icon + label / Icon-only
Full-width button

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

VariantSelectionUse
SingleExactly one activeMoney / Crypto rail selection
Single, deselectableZero or one activeOptional filters
MultiAny number activeTransaction 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

StateVisual
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-visible3px 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" with role="radio" children and aria-checked.
  • Multi-select uses role="group" with aria-pressed on each <button>.
  • The group has an accessible name via aria-label or aria-labelledby.
  • Icon-only items require aria-label on each item.
KeyAction
TabMove focus into / out of the group
← →Move between items (wraps)
Home / EndFirst / last item
SpaceToggle 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

Toggle group — view switch
Toggle group — time period

Known gaps

  • Multi-select is documented but not implemented in any shipped screen.
  • Overflow behavior for more than four items is unspecified.

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

VariantClassColorExample
Defaultbasevar(--ds-link)Forgot password?, Back to home
With iconbasevar(--ds-link)+ Add payment method
Destructive.link-destructivevar(--ds-red-600)Delete contact, Remove method

Style

PropertyInline linkStandalone link
Font sizevar(--ds-text-sm) — 14pxvar(--ds-text-sm) — 14px
Font weightvar(--ds-font-semibold) — 600var(--ds-font-semibold) — 600
Default stateUnderline, offset 2pxUnderline, offset 2px
HoverColor var(--ds-primary-hover)Color var(--ds-primary-hover)
Focus-visible3px solid var(--ds-focus) outline, 2px offset3px solid var(--ds-focus) outline, 2px offset
Disabledvar(--ds-gray-400), no underlinevar(--ds-gray-400), no underline
Heightauto — flows with the text48px min-height (h-12)
Padding0 inline, 0 block0 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

StateVisual
Default--ds-link, underlined, 2px offset
Hover--ds-primary-hover, underline persists
Focus-visible3px solid --ds-focus outline, 2px offset; underline persists
VisitedNo 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
DoDon'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-12 class)
  • Focus indicator visible on keyboard navigation
  • A link that performs an action rather than navigating is the wrong element — use a link-variant <button>
KeyAction
TabMove focus to the link
EnterActivate

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-destructive is documented as a class; no corresponding --ds-link-destructive token exists.
  • External link treatment is specified here but not implemented as a component prop.
Link styles
Default
With icon
Destructive
Inline (in body text)

View your transaction history or download a statement.