Centrim Design System — a token-first, accessible React component 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
Public profile address.
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
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.
Maximum is 40 hours.
Close to capacity.
Within the approved range.
Anatomy & border behaviour
The group owns the chrome; the parts contribute separators only.
| Concern | Owner | Token | Notes |
|---|---|---|---|
| Outer border | InputGroup | --color-border-default | One border for the whole group |
| Outer radius | InputGroup | --ds-radius-md | overflow-hidden clips the parts |
| Inner separator | Addon / button / select | --color-border-default (1px) | A single hairline, never a second box |
| Height | InputGroup size | 32 / 40 / 48px | Parts stretch to the group height |
| Focus ring | InputGroup | ds-focus-ring | focus-within, so it shows for any inner control |
| State | InputGroup | border-error / warning / success | Communicated 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.
States
Default, read-only, disabled, loading, error, warning, success — declared once on the group.
Enter an amount above zero.
Above the usual weekly spend.
Budget approved.
Accessibility
What the components guarantee, and what the composition must still get right.
| Requirement | How it is met |
|---|---|
| Accessible label | InputGroup label + htmlFor pointing at the inner control id |
| Addons stay out of the name | Decorative text and icons are aria-hidden or separately labelled |
| Icon-only controls | IconButton label, or aria-label on a checkbox / radio addon |
| Keyboard stepping | Native spinbutton arrows plus focusable stepper buttons |
| Visible focus | focus-within ring on the group; inner controls raise z-index |
| Error messaging | FieldMessage with role=alert, referenced by aria-describedby |
| Disabled communication | Real disabled attributes; the group dims via has-disabled |
| Touch targets | ds-target keeps small controls at 24px, 44px on coarse pointers |
| Not colour-only | Every 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.