Components · Actions

Button group

A layout primitive for adjacent actions. Distinct buttons with consistent spacing — never a segmented control. Use it whenever two or more buttons share a row.

Documentedby Derek Fidler

Form actions

Overview

The Button group sits between two or more <Button> children and gives them a consistent gap, direction, and alignment. It does not collapse borders or share a focus ring — each button keeps its own shape, variant, and state. Reach for it whenever buttons live next to each other; never hand-roll the layout with margins.

Not a segmented control

A segmented control merges adjacent buttons into one shape with shared corners and a single chosen state. Flatpay's button group keeps the buttons distinct — that's the design. If you need a segmented picker, reach for the upcoming SegmentedControl component instead.

Compositions

Four canonical patterns cover almost every screen we ship. Reach for the one that fits the surface — don't invent a fifth.

Form actions

Cancel + Primary

Standard footer for forms and dialogs. Cancel sits on the left, the primary action sits on the right of the row — closest to the user's reading exit.

Toolbar

Filter + Action + Action

Adjacent toolbar actions of equal weight. Use secondary or tertiary variants — never two primaries in a single bar.

Row actions

Action + Overflow

A single visible action plus an overflow menu for the rest. Common in tables, list rows, and prototype cards.

Stretch fill

Two equal buttons

Each child fills 1fr — a common shape on mobile bottom sheets and dialogs where width is precious. Use sparingly on desktop; equal-width buttons cancel hierarchy.

Orientation

Two directions. Default is horizontal — the buttons share a baseline. Vertical stacks them; reach for it on narrow surfaces only.

orientation="horizontal"

The default. Use for form footers, toolbar strips, and any context where the buttons share a baseline.

orientation="vertical"

Stacks children. Reach for it on narrow surfaces — mobile bottom sheets, sidebar action lists, narrow card footers.

Alignment

Four options. Default is start; reach for between when a back link and a forward action need the full row width; stretch only on narrow widths where buttons must fill 1fr each.

align="start"

Default. Pushes the group to the leading edge of its container.

align="end"

Pushes the group to the trailing edge — used in form footers when the action sits opposite a back link.

align="between"

Pushes the first child to the leading edge and the last to the trailing edge — ideal for Cancel ↔ Save dialogs.

align="stretch"

Each child fills 1fr. Reach for it on narrow widths (mobile, dialogs) only.

Spacing

Three gap sizes. Default is md (8 px) — the same gap used between siblings throughout the system.

gap="sm"

6 px

Tight clusters — row actions, dense toolbars, table cells.

gap="md"

8 px

The default. Form footers, dialog actions, top-level toolbars.

gap="lg"

12 px

Generous separation — splash screens, marketing surfaces, hero CTAs.

Anatomy

Three named parts. The wrapper is a role="group" container; the children remain individual buttons; the gap is owned by the wrapper, not the buttons.

  1. Wrapper

    Container with role="group". Owns direction (orientation), spacing (gap), and positioning (align).

  2. Children

    Individual <Button> components — each keeps its own variant, size, and focus ring. Mix variants freely (one primary, several secondaries) but never two primaries.

  3. Gap

    CSS gapon the wrapper. Default 8 px — sits on the system's 4-pixel grid. Children carry no horizontal margin.

Behavior

  • No shared state. The group is layout-only. Each child is its own button, with its own click, loading, and disabled handling.
  • Tab order follows the DOM. In orientation="horizontal", that means left-to-right; in vertical, top-to-bottom. Place the most likely next action last in the DOM so it earns the final tab stop before the user moves on.
  • Wrapping is opt-in. wrap={true} lets the group break to a new line when the container narrows. By default it stays on one row — overflow becomes a clipping problem you should fix with composition (drop a button into an overflow menu) rather than wrapping.
  • Stretch is responsive, not decorative. Use align="stretch" when width is constrained — mobile bottom sheets, narrow dialogs. On desktop, equal-width buttons cancel hierarchy.

Accessibility

  • Set label on every group that isn't already named by an adjacent heading. The prop emits aria-label on the role="group" wrapper — screen readers announce it once before reading the children.
  • Don't mix link and button semantics inside one group. A "View profile" link next to a "Delete" button reads as ambiguous — split them into two patterns or render the link as a button styled like a tertiary action.
  • Mobile: prefer vertical orientation on narrow widths. Three buttons in a row reach below the WCAG 24-px target spacing on most phones. Either stack vertically or reduce to two children + an overflow menu.
  • Focus ring inheritance.Each child carries its own focus ring; the group does not. Don't add an outer focus outline to the wrapper — it competes with the children's rings.

Code

Import alongside Button. The wrapper takes a label for screen readers and a few layout props; everything else stays on the children.

tsx

import { Button, ButtonGroup } from "@flatpay-dk/ui";

// Default — Cancel + primary
<ButtonGroup label="Save or discard">
  <Button variant="tertiary">Cancel</Button>
  <Button variant="primary">Save changes</Button>
</ButtonGroup>

// Toolbar — three secondaries, tighter gap
<ButtonGroup label="Catalog toolbar" gap="sm">
  <Button variant="tertiary" leadingIcon={<FilterIcon />}>Filter</Button>
  <Button variant="tertiary" leadingIcon={<DownloadIcon />}>Export</Button>
  <Button variant="tertiary">Sort</Button>
</ButtonGroup>

// Row actions — primary action + overflow menu
<ButtonGroup label="Row actions" gap="sm">
  <Button variant="tertiary" size="sm">Edit</Button>
  <Button variant="tertiary" size="sm" iconOnly aria-label="More actions">
    <MoreIcon />
  </Button>
</ButtonGroup>

// Stretch — used in mobile bottom sheets
<ButtonGroup align="stretch" label="Confirm">
  <Button variant="tertiary">Cancel</Button>
  <Button variant="primary">Confirm</Button>
</ButtonGroup>

// Vertical stack — narrow contexts
<ButtonGroup orientation="vertical" label="Mobile actions">
  <Button variant="primary">Confirm</Button>
  <Button variant="secondary">Save draft</Button>
  <Button variant="tertiary">Cancel</Button>
</ButtonGroup>

Best practices

The group is just layout, but the *composition* inside it carries weight. A few rules keep that weight readable.

Do

One primary per group. Cancel sits left, primary sits right (closest to the reading exit).

Don't

Don't pair two primaries. The user reads neither as the next step.

Do

Stretch on narrow widths. Two equal buttons fill 1fr each; the dialog reads like a clean fork.

Don't

Don't stretch on desktop. Equal-width buttons in a wide row cancel hierarchy.

Do

Move the rest into an overflow menu when a row would have four or more actions.

Don't

Don't crowd the row with five visible actions. Density isn't the same as legibility.

Props

PropTypeDefaultDescription
labelstringARIA label describing the group's purpose. Required when the group isn't already named by an adjacent heading.
orientation"horizontal" | "vertical""horizontal"Layout direction. Vertical stacks children full-width; horizontal sits them on a shared baseline.
gap"sm" | "md" | "lg""md"Distance between children. sm=6 px · md=8 px · lg=12 px.
align"start" | "end" | "between" | "stretch""start"Justification along the main axis. start/end push to one edge; between fills the row; stretch makes each child fill 1fr.
wrapbooleanfalseAllow children to wrap to the next line when the container narrows. Off by default — overflow should usually be solved by composition, not wrapping.
...restHTMLAttributes<HTMLDivElement>All standard div attributes (className, id, data-*, etc.) pass through to the wrapper.