Skip to content

Input group

An input or textarea with addons such as icons, units, prefixes and inline buttons.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { InputGroup } from "@oration/canon/components/input-group";
packages/canon/src/components/input-group.tsx

Supplier portal

Northwind Freight signs in here to see remittances and upload W-9s.

portal.cedarline.io/

Lowercase letters, numbers and dashes.

import { CopyButton } from "@oration/canon/components/copy-button";import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import {  InputGroup,  InputGroupAddon,  InputGroupButton,  InputGroupInput,  InputGroupText,} from "@oration/canon/components/input-group";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { MailIcon } from "lucide-react";import * as React from "react";export function Hero() {    const id = React.useId();    const [slug, setSlug] = React.useState("northwind-freight");    const [sending, setSending] = React.useState(false);    const url = `https://portal.cedarline.io/${slug}`;    return (        <div className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-1">                <p className="text-sm font-semibold text-foreground">                    Supplier portal                </p>                <p className="text-13 text-muted-foreground">                    Northwind Freight signs in here to see remittances and                    upload W-9s.                </p>            </div>            <Field>                <FieldLabel htmlFor={`${id}-slug`}>Portal address</FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <InputGroupText className="font-mono text-xs">                            portal.cedarline.io/                        </InputGroupText>                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-slug`}                        value={slug}                        onChange={(event) =>                            setSlug(                                event.target.value                                    .toLowerCase()                                    .replace(/[^a-z0-9-]/g, ""),                            )                        }                        autoComplete="off"                        spellCheck={false}                        className="font-mono"                        aria-describedby={`${id}-slug-description`}                    />                    <InputGroupAddon align="inline-end">                        <CopyButton                            value={url}                            label="Copy portal address"                            size="icon-xs"                            onCopied={() =>                                toast.add({                                    title: "Copied portal address",                                    description: url,                                })                            }                        />                    </InputGroupAddon>                </InputGroup>                <FieldDescription                    id={`${id}-slug-description`}                    className="text-[13px]"                >                    Lowercase letters, numbers and dashes.                </FieldDescription>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-email`}>Invite contact</FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <MailIcon aria-hidden="true" />                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-email`}                        type="email"                        defaultValue="ap@northwindfreight.com"                    />                    <InputGroupAddon align="inline-end">                        <InputGroupButton                            disabled={sending}                            onClick={() => {                                setSending(true);                                window.setTimeout(() => {                                    setSending(false);                                    toast.add({                                        type: "success",                                        title: "Portal invite sent",                                        description:                                            "Northwind Freight can sign in now.",                                    });                                }, 900);                            }}                        >                            {sending ? <Spinner className="size-3.5" /> : null}                            Send invite                        </InputGroupButton>                    </InputGroupAddon>                </InputGroup>            </Field>        </div>    );}

Usage#

Input group puts an input or textarea and its addons in one 32px box: a leading icon, a text prefix such as $ or portal.cedarline.io/, a unit such as % or days, or an inline button such as copy, clear or show password. The box focuses and turns invalid as one control. The mistake people make is dropping a plain Input inside it: only InputGroupInput and InputGroupTextarea strip the inner border and carry the data-slot the group's focus ring listens for.

When to use

  • For a value with a fixed prefix or suffix: a portal address after portal.cedarline.io/, an amount after $, a discount before %.
  • For a leading icon that says what the field holds, such as a mail or search glyph.
  • For one or two small inline actions that act on the field's value: copy, clear, paste, show password, verify.
  • For a textarea with a header or toolbar row inside the same box, using block-start and block-end addons.
  • As the base of composed fields: Password input and Key-value editor are built on it.

When not to use

  • For a plain text field with nothing attached. Use Input
  • For a password with a show and hide toggle. It is already built. Use Password input
  • For a read-only key, ID or URL that people copy. Use Copy row
  • For a toolbar search box with a leading glyph. Use Search field
  • For a unit that people choose, such as seconds or minutes. Use Duration picker

The Machine Mono Rule

Prefixes and values that a machine will parse, such as URLs, slugs and IDs, are set in Geist Mono. Currency symbols, units and words stay in Geist Sans.

Addons describe, buttons act

Text and icon addons are part of the value's label and never take focus. An inline button is a real control: it has a name, a tooltip when it is icon-only, and it acts only on this field.

Anatomy#

$
USD
  1. Group. A role="group" box, 32px tall with 10px corners and a 1px Field Stroke. It grows to fit a textarea or block addons.
  2. Leading addon. InputGroupAddon align="inline-start" holding InputGroupText or a 16px icon in Slate Meta. Clicking it focuses the input.
  3. Control. InputGroupInput or InputGroupTextarea: the input with its own border, ring and fill removed. Its padding tightens to 6px beside an addon.
  4. Trailing addon. align="inline-end" for units, keyboard hints and InputGroupButtons, pulled 5px toward the edge when it holds a button.
  5. Focus ring. Drawn on the group, not the input: an indigo border and a 3px ring at 50% while the control has focus.

Examples#

Prefixes and units

InputGroupText in a leading or trailing addon holds the fixed part of a value: a currency, a unit, a word. People type only the number.

$
USD
%

2% off when paid within 10 days.

Net
days
import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@oration/canon/components/input-group";import * as React from "react";export function TextAddons() {    const id = React.useId();    const [discount, setDiscount] = React.useState("2");    return (        <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-3">            <Field>                <FieldLabel htmlFor={`${id}-amount`}>Invoice amount</FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <InputGroupText>$</InputGroupText>                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-amount`}                        inputMode="decimal"                        placeholder="48,250.00"                        className="tabular-nums"                    />                    <InputGroupAddon align="inline-end">                        <InputGroupText>USD</InputGroupText>                    </InputGroupAddon>                </InputGroup>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-discount`}>                    Early-pay discount                </FieldLabel>                <InputGroup>                    <InputGroupInput                        id={`${id}-discount`}                        inputMode="decimal"                        value={discount}                        onChange={(event) => setDiscount(event.target.value)}                        className="tabular-nums"                        aria-describedby={`${id}-discount-description`}                    />                    <InputGroupAddon align="inline-end">                        <InputGroupText>%</InputGroupText>                    </InputGroupAddon>                </InputGroup>                <FieldDescription                    id={`${id}-discount-description`}                    className="text-[13px]"                >                    {Number(discount) > 0                        ? `${discount}% off when paid within 10 days.`                        : "No discount for paying early."}                </FieldDescription>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-terms`}>Payment terms</FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <InputGroupText>Net</InputGroupText>                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-terms`}                        inputMode="numeric"                        defaultValue="30"                        className="tabular-nums"                    />                    <InputGroupAddon align="inline-end">                        <InputGroupText>days</InputGroupText>                    </InputGroupAddon>                </InputGroup>            </Field>        </div>    );}

Icons and hints

A 16px Slate Meta icon says what the field holds; a trailing key hints at a shortcut and a spinner shows a check in progress.

/
import { Field, FieldLabel } from "@oration/canon/components/field";import { InputGroup, InputGroupAddon, InputGroupInput } from "@oration/canon/components/input-group";import { Kbd } from "@oration/canon/components/kbd";import { Spinner } from "@oration/canon/components/spinner";import { MailIcon, SearchIcon } from "lucide-react";import * as React from "react";export function Icons() {    const id = React.useId();    const [checking, setChecking] = React.useState(false);    const [domain, setDomain] = React.useState("northwindfreight.com");    React.useEffect(() => {        if (!domain) return;        setChecking(true);        const timer = window.setTimeout(() => setChecking(false), 700);        return () => window.clearTimeout(timer);    }, [domain]);    return (        <div className="grid w-full max-w-xl gap-4 sm:grid-cols-2">            <Field>                <FieldLabel htmlFor={`${id}-search`}>                    Find a supplier                </FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <SearchIcon aria-hidden="true" />                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-search`}                        placeholder="Name or vendor ID"                    />                    <InputGroupAddon align="inline-end">                        <Kbd>/</Kbd>                    </InputGroupAddon>                </InputGroup>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-domain`}>Email domain</FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <MailIcon aria-hidden="true" />                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-domain`}                        value={domain}                        onChange={(event) => setDomain(event.target.value)}                        className="font-mono"                        spellCheck={false}                    />                    <InputGroupAddon align="inline-end">                        {checking ? (                            <Spinner aria-label="Checking domain" />                        ) : null}                    </InputGroupAddon>                </InputGroup>            </Field>        </div>    );}

Inline buttons

InputGroupButton is 24px: icon-xs for a named icon action with a tooltip, xs for a short verb. Keep it to one or two, acting on this value.

$
import { Field, FieldLabel } from "@oration/canon/components/field";import {  InputGroup,  InputGroupAddon,  InputGroupButton,  InputGroupInput,  InputGroupText,} from "@oration/canon/components/input-group";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ClipboardPasteIcon, XIcon } from "lucide-react";import * as React from "react";export function Buttons() {    const id = React.useId();    const [amount, setAmount] = React.useState("12,480.00");    const [code, setCode] = React.useState("");    return (        <div className="grid w-full max-w-xl gap-4 sm:grid-cols-2">            <Field>                <FieldLabel htmlFor={`${id}-amount`}>                    Remittance amount                </FieldLabel>                <InputGroup>                    <InputGroupAddon>                        <InputGroupText>$</InputGroupText>                    </InputGroupAddon>                    <InputGroupInput                        id={`${id}-amount`}                        value={amount}                        onChange={(event) => setAmount(event.target.value)}                        className="tabular-nums"                    />                    {amount ? (                        <InputGroupAddon align="inline-end">                            <Tooltip>                                <TooltipTrigger                                    render={                                        <InputGroupButton                                            size="icon-xs"                                            aria-label="Clear amount"                                            onClick={() => setAmount("")}                                        />                                    }                                >                                    <XIcon />                                </TooltipTrigger>                                <TooltipContent>Clear amount</TooltipContent>                            </Tooltip>                        </InputGroupAddon>                    ) : null}                </InputGroup>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-code`}>                    Bank verification code                </FieldLabel>                <InputGroup>                    <InputGroupInput                        id={`${id}-code`}                        value={code}                        onChange={(event) => setCode(event.target.value)}                        placeholder="From the $0.12 deposit"                        className="font-mono"                    />                    <InputGroupAddon align="inline-end">                        <InputGroupButton                            onClick={() => {                                setCode("CDL-4821");                                toast.add({                                    title: "Pasted from the clipboard",                                });                            }}                        >                            <ClipboardPasteIcon />                            Paste                        </InputGroupButton>                        <InputGroupButton                            variant="secondary"                            onClick={() =>                                toast.add({                                    type: code ? "success" : "error",                                    title: code                                        ? "Bank account verified"                                        : "Enter the code first",                                })                            }                        >                            Verify                        </InputGroupButton>                    </InputGroupAddon>                </InputGroup>            </Field>        </div>    );}

Textarea with header and toolbar

block-start and block-end addons span the full width above and below an InputGroupTextarea, for a recipient line, a counter and a send action.

To Northwind Freight, ap@northwindfreight.com
59/280
import { Field, FieldLabel } from "@oration/canon/components/field";import {  InputGroup,  InputGroupAddon,  InputGroupButton,  InputGroupText,  InputGroupTextarea,} from "@oration/canon/components/input-group";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { PaperclipIcon } from "lucide-react";import * as React from "react";export function WithTextarea() {    const id = React.useId();    const [note, setNote] = React.useState(        "Payment for INV-20417 and INV-20418 goes out Friday, Oct 2.",    );    const limit = 280;    return (        <Field className="max-w-lg">            <FieldLabel htmlFor={`${id}-note`}>Note to supplier</FieldLabel>            <InputGroup>                <InputGroupAddon                    align="block-start"                    className="border-b border-border"                >                    <InputGroupText className="text-[13px] font-normal">                        To Northwind Freight, ap@northwindfreight.com                    </InputGroupText>                </InputGroupAddon>                <InputGroupTextarea                    id={`${id}-note`}                    value={note}                    maxLength={limit}                    onChange={(event) => setNote(event.target.value)}                    rows={3}                />                <InputGroupAddon                    align="block-end"                    className="border-t border-border"                >                    <Tooltip>                        <TooltipTrigger                            render={                                <InputGroupButton                                    size="icon-xs"                                    aria-label="Attach remittance PDF"                                    onClick={() =>                                        toast.add({                                            title: "Attached remittance-2026-10-02.pdf",                                        })                                    }                                />                            }                        >                            <PaperclipIcon />                        </TooltipTrigger>                        <TooltipContent>Attach remittance PDF</TooltipContent>                    </Tooltip>                    <InputGroupText className="ml-auto text-xs font-normal tabular-nums">                        {note.length}/{limit}                    </InputGroupText>                    <InputGroupButton                        variant="default"                        onClick={() =>                            toast.add({                                type: "success",                                title: "Note sent to Northwind Freight",                            })                        }                    >                        Send note                    </InputGroupButton>                </InputGroupAddon>            </InputGroup>        </Field>    );}

States#

Rest
$
USD
Focus
$
USD
Invalid
$
USD
Disabled
$
USD
Read-only
$
USD
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@oration/canon/components/input-group";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        {            name: "Rest",            group: "",            invalid: false,            disabled: false,            readOnly: false,        },        {            name: "Focus",            group: "border-ring ring-3 ring-ring/50",            invalid: false,            disabled: false,            readOnly: false,        },        {            name: "Invalid",            group: "",            invalid: true,            disabled: false,            readOnly: false,        },        {            name: "Disabled",            group: "",            invalid: false,            disabled: true,            readOnly: false,        },        {            name: "Read-only",            group: "",            invalid: false,            disabled: false,            readOnly: true,        },    ];    return (        <div className="grid w-full grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-5">            {states.map((state) => (                <div key={state.name} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <InputGroup                        data-disabled={state.disabled || undefined}                        className={cn("pointer-events-none", state.group)}                    >                        <InputGroupAddon>                            <InputGroupText>$</InputGroupText>                        </InputGroupAddon>                        <InputGroupInput                            aria-label={`Amount, ${state.name}`}                            tabIndex={-1}                            defaultValue={state.invalid ? "12,48O" : "12,480"}                            disabled={state.disabled}                            readOnly={state.readOnly}                            aria-invalid={state.invalid || undefined}                            className="tabular-nums"                        />                        <InputGroupAddon align="inline-end">                            <InputGroupText>USD</InputGroupText>                        </InputGroupAddon>                    </InputGroup>                </div>            ))}        </div>    );}
States
StateTreatment
Rest1px Field Stroke, transparent fill (Field Stroke at 30% in dark).
Focus visibleWhen the control is focused, the group takes an indigo border and a 3px Focus Indigo ring at 50%. Inside a combobox popup the ring is suppressed.
InvalidAny control inside with aria-invalid turns the group's border red with a 3px red ring at 20% (40% in dark).
DisabledA disabled control inside dims the whole group to 50% on a Field Stroke tint. Set data-disabled on the group to dim the addons too.
Read-onlyA readOnly input looks like rest and still focuses; pair it with a copy button.

Behavior#

  • Addons use CSS order, so inline-start always draws first and inline-end last. Keep the DOM order the same as the visual order so Tab moves left to right.
  • Clicking an addon anywhere except on a button focuses the group's input.
  • InputGroupButton defaults to type="button", variant="ghost" and size="xs" (24px). icon-xs is a 24px square for icon actions.
  • block-start and block-end addons turn the group into a column: a header or toolbar row that spans the full width above or below the control.
  • With a textarea the group's height is automatic, and the textarea grows with its content.
  • Pass height changes through the group's className, such as h-9 on auth screens.

Do and don't#

portal.cedarline.io/
Do. Put the fixed part of a value in an addon so people type only the part that changes.
Don't. Pre-fill the fixed part into the input, where it can be deleted and has to be validated again.
Do. Name icon-only inline buttons and give them a tooltip, such as Clear amount.
Don't. Leave a bare X with no name. Screen reader users hear only button.
Do. Use one or two inline actions that act on this value.
Don't. Crowd the field with actions that belong in the form footer, such as Save or Cancel.

Content#

  • Prefixes are exact: https://, portal.cedarline.io/, $. Don't add a trailing space or a colon.
  • Units are words or standard symbols: days, %, USD. Match the unit the value is stored in.
  • Inline button labels are one verb: Verify, Paste, Send test.
  • Keep the label outside the group, in a Field above it. The prefix is not a label.

Accessibility#

  • The group has role="group"; name the control itself with a <label htmlFor> or aria-label.
  • Text addons are not read as part of the input's name. When the prefix matters, repeat it in the label or description: Portal address, after portal.cedarline.io/.
  • Icon-only InputGroupButtons need an aria-label and a tooltip with the same words.
  • The 24px inline buttons meet the 24px minimum target. On touch-first screens raise the group to h-9 and use icon-sm.
  • Toggle buttons such as show password expose their state with aria-pressed.
Keyboard interactions
KeysAction
TabMoves to the control, then to each inline button in DOM order. Addon text is skipped.
EnterActivates a focused inline button.

Design tokens#

Design tokens
TokenUsed for
--inputGroup stroke; 30% fill in dark; 50% tint when disabled
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and 3px ring at 20%
--muted-foregroundAddon text and icons
--mutedHover fill of ghost inline buttons
--radius-lg10px group corners
--radiusInline buttons at --radius minus 3px, keys at minus 5px

API reference#

InputGroup

The box that holds the control and its addons.

Other props spread onto <div>.

Props of InputGroup
PropTypeDefaultDescription
data-disabledbooleanNo defaultDims the addons along with a disabled control.
classNamestringNo defaultMerged last. Use it for width and height, such as h-9 or sm:w-72.

InputGroupAddon

A slot for text, icons, keys or buttons. Clicking it focuses the input.

Other props spread onto <div>.

Props of InputGroupAddon
PropTypeDefaultDescription
align"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"Before or after the control on the same line, or as a full-width row above or below it.

InputGroupInput

The input, with its own border, ring and background removed.

Other props spread onto Input (<input>).

No props of its own.

InputGroupTextarea

The textarea version. Grows with its content and doesn't resize by hand.

Other props spread onto Textarea (<textarea>).

No props of its own.

InputGroupText

Slate Meta text for prefixes and units inside an addon.

Other props spread onto <span>.

No props of its own.

InputGroupButton

A compact button sized for the group.

Other props spread onto Button, without its size.

Props of InputGroupButton
PropTypeDefaultDescription
size"xs" | "sm" | "icon-xs" | "icon-sm""xs"24px text button, 24px square icon button, or the 32px sm and icon-sm.
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""ghost"Button variant. Ghost keeps the field quiet.
type"button" | "submit" | "reset""button"Unlike Button, it doesn't submit by default.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

InputGroupButton size="sm" adds no classes, so it renders at Button's default 32px, and icon-sm is a 32px square. Both are as tall as the 32px group and overflow its 1px border. Use xs or icon-xs unless the group is h-9.

Inline buttons use --radius minus 3px (7px) and keys minus 5px (5px); neither is on the 6, 8, 10 and 12px ramp.

Clicking an addon focuses the group's first input, so with InputGroupTextarea a click on a block addon doesn't focus the textarea.

Settings keeps its own password field (apps/web/src/components/settings/account-security/password-input.tsx) built from these parts instead of using Password input.