Input Group
A search icon, a https:// prefix, a clear button, a character counter — the
things that belong inside a text field but are not the value. InputGroup
draws one control boundary around a bare Input or
Textarea and any number of addons, and the whole
assembly focuses, hovers, disables and errors as a single control — because to
the person using it, it is one.
Focus lives on the control and the group rings for it, exactly the way Number Field and Combobox ring their shells. Clicking anywhere in the box — a prefix, an icon, the padding — places the caret, the same gesture the empty end of a plain input offers; buttons inside keep their clicks to themselves.
Import
import { InputGroup } from "@forte-ui/react";Anatomy
<InputGroup.Root>
<InputGroup.Addon>{/* icons, <InputGroup.Text>, buttons */}</InputGroup.Addon>
<InputGroup.Input /> {/* or <InputGroup.Textarea /> */}
<InputGroup.Addon align="inline-end">
<InputGroup.Button iconOnly aria-label="…">…</InputGroup.Button>
</InputGroup.Addon>
</InputGroup.Root>align places an addon on one of the four edges: the inline pair shares the
control's row, the block pair takes a full-width row above or below.
Placement is visual, driven by CSS order, so DOM order is free to follow
focus order — an addon holding a button can sit after the control in the
source and still draw at the start edge. Alignment is logical, so a group is
correct in RTL with no extra work.
Examples
Text affixes
Wrap words in <InputGroup.Text> — a protocol, a unit, a domain. The affixes
are visual; give assistive technology the same information through the field's
label or aria-describedby.
Protocol and domain are added for you.
Buttons
<InputGroup.Button> is Button preset for life inside a field: ghost +
neutral so it reads as part of the control, sized to the field's inner
height, and wearing .forte-target so the hit area stays 24×24 even where the
visual box dips under it. Every Button prop passes through — turn the end
addon into a primary action with variant="solid" when the design calls for
one.
Loading
A Spinner in an addon announces itself — it is its own
live region, so there is nothing to wire beyond its label.
Multi-line
<InputGroup.Textarea> is the multi-line control, with Textarea's row
maths: rows is the floor, maxRows the ceiling, autoResize moves between
them. Block addons are the natural companions — a toolbar above, a footer with
a counter and a send button below.
Sizes
size moves the group's height, inline padding and font size together — for
the control and every addon at once — on the same --forte-control-* scale the
rest of the set reads, so a group and a select on one row line up at every
density.
Variants
The same three Input has, because the group is the input to a reader:
outline reads as a form control, soft as a filled field, ghost as an
inline affordance.
States
The group has no disabled, readOnly or invalid props, and that is the
point: every state lives on the control (or the Field
above it), and the shell watches it through :has() — one source of truth, no
way for the box and the control to disagree. Disabled dims the whole group;
read-only tints it; invalid recolours the boundary and the caret, and nothing
else — recolouring the value or the addons would make the user's own answer
look like the error message.
In a Field
InputGroup.Input is Field.Control — the same primitive Input is — so
a group inside a Field.Root picks up the label, the description, the error
message, name and the validity attributes with no wiring.
Lowercase letters, numbers and dashes.
Accessibility
The group's <div> is role="presentation": what assistive technology should
meet is the control and the buttons, not the box around them.
- Name the control, not the group —
<Field.Label>oraria-labelonInputGroup.Input/InputGroup.Textarea. - Decorative icons get
aria-hidden="true". The group cannot know which children carry meaning, so it hides nothing for you. - Affixes that carry information — a currency, a unit, a count — should
reach the control through
aria-describedbypointing at the<InputGroup.Text>'sid, or be folded into the field's description. A sighted user reads "$" next to the value; the label should say as much. - Icon-only buttons need
aria-label, same as everywhere else. - Keep DOM order matching focus order. Placement is CSS
order, so a start-edge addon holding a button can — and should — come after the control in the source when tabbing to it after the field is the natural reading.
Consumers can target the group's watched states from plain CSS or Tailwind the
same way the shell does: has-[[data-disabled]]:opacity-50,
has-[[data-invalid]]:….
In forced-colors mode the box is restated as Field / FieldText with a
FieldText boundary — the root is a <div>, so the UA assigns it nothing on
its own, and the soft / ghost variants' transparent border would
otherwise be replaced with an arbitrary system colour. A disabled group also
restates opacity: 1 and GrayText: opacity is one of the few things
forced-colors does not override, and the generic remedy keys on a
data-disabled the root deliberately does not carry.
Theming
Every --forte-input-group-* property is declared on the group element itself,
and an element's own declaration beats an inherited one — so set them on the
group, through className (an unlayered rule beats
@layer forte.components at any specificity) or an inline style. The
global tokens they resolve to — --forte-color-*, --forte-control-*,
--forte-radius-* — are what to re-point from :root or a theme scope to move
every group at once.
| Property | Controls | Default |
|---|---|---|
--forte-input-group-height | Minimum height of the group (reset per size) | var(--forte-control-h-md) |
--forte-input-group-px | Inline padding between the border and the content (reset per size) | var(--forte-control-px-md) |
--forte-input-group-py | Block padding; also the inset buttons keep from the border | var(--forte-space-1) |
--forte-input-group-font-size | Font size of the control and the addons (reset per size) | var(--forte-font-size-2) |
--forte-input-group-gap | Gap between the control and each addon, and between an addon's items | var(--forte-control-gap) |
--forte-input-group-radius | Corner radius | var(--forte-radius-control) |
--forte-input-group-border-width | Border width | 1px |
--forte-input-group-icon-size | Icon size inside addons | var(--forte-font-size-3) |
--forte-input-group-button-size | Size of buttons inside addons: block size always, inline size too when iconOnly. Fills the field's inner height, so at sm it dips below the 24px SC 2.5.8 floor — which is why InputGroup.Button carries .forte-target: the HIT area stays 24x24 while the visual box tracks the field | var(--forte-input-group-inner-h) |
--forte-input-group-bg | Background (reset per variant) | var(--forte-color-background) |
--forte-input-group-bg-hover | Background on hover | var(--forte-color-panel-hover) |
--forte-input-group-fg | Text colour of the control | var(--forte-color-foreground) |
--forte-input-group-addon-fg | Addon text and icon colour | var(--forte-color-foreground-muted) |
--forte-input-group-placeholder-fg | Placeholder colour | var(--forte-color-foreground-muted) |
--forte-input-group-border-color | Border colour (reset per variant, and by the control's data-invalid) | var(--forte-color-border) |
--forte-input-group-border-color-hover | Border colour on hover | var(--forte-color-border-strong) |
--forte-input-group-caret-color | Caret colour | var(--forte-color-primary) |
--forte-input-group-selection-bg | Selection background | var(--forte-color-primary-soft) |
--forte-input-group-selection-fg | Selection text colour | var(--forte-color-primary-text) |
API reference
InputGroup.Root
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
fullWidth | boolean | false | Stretch the group to fill the width of its container. Only needed outside a `Field.Root` — a field is a flex column, so a group inside one already stretches. |
size | InputGroupSize | md | Size of the group. Height, inline padding and font size move together for the control and every addon at once, and the numbers come from the same `--forte-control-*` tokens `Input` and `Select.Trigger` read — so a group and a plain input on one row are the same height at every `data-forte-density` setting. |
variant | InputGroupVariant | outline | How much visual weight the group carries. `outline` reads as a form control, `soft` as a filled field, `ghost` as an inline affordance — the same three `Input` has, because to a reader the group *is* the input. |
InputGroup.Input
Everything else is a native <input> prop and passes straight through, plus
Base UI's render, onValueChange and defaultValue.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
InputGroup.Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
autoResize | boolean | false | Grow with the content as the user types, between `rows` and `maxRows` — the same behaviour, and the same `field-sizing` implementation with the same fallback, as `Textarea`'s `autoResize`. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
maxRows | number | The number of rows the box may grow to before it starts scrolling instead. Only a ceiling: a textarea shorter than this is unaffected. Unset means no ceiling. | |
onValueChange | ((value: string, eventDetails: { reason: "none"; event: Event; cancel: () => void; allowPropagation: () => void; isCanceled: boolean; isPropagationAllowed: boolean; trigger: Element; }) => void) | Callback fired when the value changes. Use when controlled. | |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, FieldControlState> | Replace the rendered element. Defaults to a `<textarea>`. | |
rows | number | 3 | The number of rows the box is at its shortest. Sets the native `rows` attribute and the floor the control can never shrink below — including under `autoResize`. |
InputGroup.Addon
| Prop | Type | Default | Description |
|---|---|---|---|
align | InputGroupAddonAlign | inline-start | Which edge of the control the addon sits against. The `inline` pair shares the control's row; the `block` pair takes a full-width row of its own, above or below — the shape a textarea's toolbar or footer wants. Alignment is logical, so `inline-start` is the left edge in LTR and the right edge in RTL with no extra work. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
InputGroup.Text
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
InputGroup.Button
Accepts every Button prop; only the defaults differ.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
focusableWhenDisabled | boolean | true while `loading`, otherwise false | Keep the button focusable while it is disabled. A native `disabled` button is blurred by the browser, which drops focus to `<body>` and loses the user's place in the tab order — and takes `aria-busy` and `loadingLabel` out of earshot with it. Left unset, this turns itself on for the duration of `loading` so the busy state is actually announced; pass it explicitly to override. |
fullWidth | boolean | false | Stretch the button to fill the width of its container. |
iconOnly | boolean | false | Render as a square button sized for a single icon. Enforces the 24px minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label`. |
loading | boolean | false | Show a busy indicator and block interaction. The label keeps its space so the button cannot resize mid-interaction. |
loadingLabel | string | Loading | Announced to assistive technology while `loading` is true. Without it, a screen reader user gets no signal that anything is happening. |
size | ButtonSize | sm | Size of the button. Actual dimensions also follow the ambient `data-forte-density` setting. |
tone | ButtonTone | neutral | Which semantic colour set the button draws from. Combines freely with `variant` — `tone="danger" variant="outline"` is a low-emphasis destructive action. |
variant | ButtonVariant | ghost | How much visual weight the button carries. |