Design System

Input groups & composite controls

One border, one height, one focus ring: how prefixes, icons, buttons, dropdowns and choice controls join an input without becoming a second styling system.

What is this?

An input group joins a control with the affordances around it — a prefix, a unit, a lookup button, a scope dropdown, a checkbox that enables the field — inside a single bordered surface that behaves as one control.

When to use it

  • The value needs a fixed prefix or unit (currency, protocol, minutes).
  • The field is submitted together with a scope or unit choice.
  • An action belongs to the field itself: search, copy, invite, scan.

How to use it

  • Set label, size and state on InputGroup — never on the parts inside it.
  • Compose from InputGroupText, InputGroupIcon, InputGroupField, InputGroupAddon, InputGroupButton, InputGroupSelect, InputGroupDropdown and InputGroupStepper.
  • Use NumberInput for numeric entry; reach for InputGroupStepper only when the field also carries addons.

What to avoid

  • Stacking a bordered Input next to a Button to fake a group — it doubles the border and breaks the focus ring.
  • Overriding height, radius or border colour on a single part.
  • Putting an essential instruction into an addon: addons are aria-hidden or separately labelled.

InputGroup

A field wrapper that owns the border, height, radius, focus ring and validation state for everything composed inside it.

import { InputGroup, InputGroupField, InputGroupText } from "@/design-system";

When to use

  • A control needs a static prefix, suffix, icon or attached action.
  • Several parts must read as one coherent control.

When not to use

  • A plain field with a decorative icon — use Input with trailingIcon.
  • Two unrelated controls side by side — use a form layout, not a group.

Prefix & suffix

https://

Public profile address.

£.00
minutes
@centrim.example

Icon combinations

Button addons

Dropdown addons

InputGroupDropdown for scope and actions; InputGroupSelect when the choice is part of the submitted value.

The currency is submitted with the value.

Checkbox & radio addons

Accessibility

  • label is rendered through FieldLabel and associated with the inner control via htmlFor.
  • Decorative addons are aria-hidden; interactive addons carry their own accessible name.
  • The focus ring is drawn on the group, so focus is visible wherever it lands inside it.
  • Validation messages pair a token colour with an icon and wording — never colour alone.
  • loading sets aria-busy on the group and renders a labelled spinner.

NumberInput

The standardized numeric field: min, max, step, keyboard arrows and accessible increment / decrement controls.

import { NumberInput } from "@/design-system";

When to use

  • A bounded numeric value is entered or nudged: quantities, hours, percentages.

When not to use

  • Free-form numeric strings such as phone numbers or references — use Input.
  • Choosing from a small fixed set — use Select or SegmentedControl.

Number input

Between 1 and 21.

Set by payroll.

Close to capacity.

Within the approved range.

£
°C

Accessibility

  • role=spinbutton with aria-valuenow / aria-valuemin / aria-valuemax.
  • Up / down arrows and Page Up / Page Down step the value natively.
  • Stepper buttons are real buttons with the accessible names “Increase value” / “Decrease value”.
  • Steppers disable at the bound instead of silently clamping without feedback.
  • Read-only removes the steppers and sets aria-readonly.

Anatomy & border behaviour

The group owns the chrome; the parts contribute separators only.

Which part owns which visual decision.
ConcernOwnerTokenNotes
Outer borderInputGroup--color-border-defaultOne border for the whole group
Outer radiusInputGroup--ds-radius-mdoverflow-hidden clips the parts
Inner separatorAddon / button / select--color-border-default (1px)A single hairline, never a second box
HeightInputGroup size32 / 40 / 48pxParts stretch to the group height
Focus ringInputGroupds-focus-ringfocus-within, so it shows for any inner control
StateInputGroupborder-error / warning / successCommunicated across the whole group

Why the group owns the chrome

If each part drew its own border, a prefix next to an input would produce two 1px lines and two radii. The group draws a single border and clips its children, so the parts only ever contribute one hairline separator. Nothing inside a group sets its own radius, height or border colour — which is also what keeps groups aligned with plain Input, Select, Button and IconButton at the same size.

Sizes

Small for dense toolbars, medium as the default, large for focused single-task forms. Every part inherits the group size.

https://
https://
https://

States

Default, read-only, disabled, loading, error, warning, success — declared once on the group.

£
£
£
£
£
£

Above the usual weekly spend.

£

Budget approved.

£

Accessibility

What the components guarantee, and what the composition must still get right.

Accessibility contract for composite controls.
RequirementHow it is met
Accessible labelInputGroup label + htmlFor pointing at the inner control id
Addons stay out of the nameDecorative text and icons are aria-hidden or separately labelled
Icon-only controlsIconButton label, or aria-label on a checkbox / radio addon
Keyboard steppingNative spinbutton arrows plus focusable stepper buttons
Visible focusfocus-within ring on the group; inner controls raise z-index
Error messagingFieldMessage with role=alert, referenced by aria-describedby
Disabled communicationReal disabled attributes; the group dims via has-disabled
Touch targetsds-target keeps small controls at 24px, 44px on coarse pointers
Not colour-onlyEvery state message pairs colour with an icon and wording

Do / Don't

Do

  • Set label, size, and validation state on InputGroup so the whole group agrees.
  • Use InputGroupSelect when the choice is submitted, InputGroupDropdown when it only changes scope or triggers an action.
  • Give every addon control its own accessible name — a checkbox inside a field is not covered by the field label.
  • Use NumberInput for bounded numeric entry instead of hand-building a spinner.

Don't

  • Don't place an Input and a Button next to each other and call it a group — the borders double up.
  • Don't set height, radius or border utilities on a part inside the group.
  • Don't mix sizes inside one group; a mismatched button or icon breaks vertical alignment.
  • Don't signal an error by colouring the border only — always pass errorText.