Skip to content

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.

input-group/basic.tsx

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.

input-group/text-addons.tsx

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.

input-group/buttons.tsx

Loading

A Spinner in an addon announces itself — it is its own live region, so there is nothing to wire beyond its label.

input-group/loading.tsx

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.

input-group/textarea.tsx

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.

input-group/sizes.tsx

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.

input-group/variants.tsx

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.

input-group/states.tsx
Enter a valid domain.

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.

input-group/field.tsx

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> or aria-label on InputGroup.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-describedby pointing at the <InputGroup.Text>'s id, 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.

Theming tokens for InputGroup
PropertyControlsDefault
--forte-input-group-heightMinimum height of the group (reset per size)var(--forte-control-h-md)
--forte-input-group-pxInline padding between the border and the content (reset per size)var(--forte-control-px-md)
--forte-input-group-pyBlock padding; also the inset buttons keep from the bordervar(--forte-space-1)
--forte-input-group-font-sizeFont size of the control and the addons (reset per size)var(--forte-font-size-2)
--forte-input-group-gapGap between the control and each addon, and between an addon's itemsvar(--forte-control-gap)
--forte-input-group-radiusCorner radiusvar(--forte-radius-control)
--forte-input-group-border-widthBorder width1px
--forte-input-group-icon-sizeIcon size inside addonsvar(--forte-font-size-3)
--forte-input-group-button-sizeSize 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 fieldvar(--forte-input-group-inner-h)
--forte-input-group-bgBackground (reset per variant)var(--forte-color-background)
--forte-input-group-bg-hoverBackground on hovervar(--forte-color-panel-hover)
--forte-input-group-fgText colour of the controlvar(--forte-color-foreground)
--forte-input-group-addon-fgAddon text and icon colourvar(--forte-color-foreground-muted)
--forte-input-group-placeholder-fgPlaceholder colourvar(--forte-color-foreground-muted)
--forte-input-group-border-colorBorder colour (reset per variant, and by the control's data-invalid)var(--forte-color-border)
--forte-input-group-border-color-hoverBorder colour on hovervar(--forte-color-border-strong)
--forte-input-group-caret-colorCaret colourvar(--forte-color-primary)
--forte-input-group-selection-bgSelection backgroundvar(--forte-color-primary-soft)
--forte-input-group-selection-fgSelection text colourvar(--forte-color-primary-text)

API reference

InputGroup.Root

Props for InputGroupRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
fullWidthbooleanfalseStretch 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.
sizeInputGroupSizemdSize 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.
variantInputGroupVariantoutlineHow 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.

Props for InputGroupInput
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

InputGroup.Textarea

Props for InputGroupTextarea
PropTypeDefaultDescription
autoResizebooleanfalseGrow 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`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
maxRowsnumberThe 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.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, FieldControlState>Replace the rendered element. Defaults to a `<textarea>`.
rowsnumber3The 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

Props for InputGroupAddon
PropTypeDefaultDescription
alignInputGroupAddonAligninline-startWhich 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.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

InputGroup.Text

Props for InputGroupText
PropTypeDefaultDescription
classNamestringAdditional 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.

Props for InputGroupButton
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
focusableWhenDisabledbooleantrue while `loading`, otherwise falseKeep 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.
fullWidthbooleanfalseStretch the button to fill the width of its container.
iconOnlybooleanfalseRender 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`.
loadingbooleanfalseShow a busy indicator and block interaction. The label keeps its space so the button cannot resize mid-interaction.
loadingLabelstringLoadingAnnounced to assistive technology while `loading` is true. Without it, a screen reader user gets no signal that anything is happening.
sizeButtonSizesmSize of the button. Actual dimensions also follow the ambient `data-forte-density` setting.
toneButtonToneneutralWhich semantic colour set the button draws from. Combines freely with `variant` — `tone="danger" variant="outline"` is a low-emphasis destructive action.
variantButtonVariantghostHow much visual weight the button carries.