Input group
An input or textarea with addons such as icons, units, prefixes and inline buttons.
Supplier portal
Northwind Freight signs in here to see remittances and upload W-9s.
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-startandblock-endaddons. - 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
Addons describe, buttons act
Anatomy#
- Group. A
role="group"box, 32px tall with 10px corners and a 1px Field Stroke. It grows to fit a textarea or block addons. - Leading addon.
InputGroupAddon align="inline-start"holdingInputGroupTextor a 16px icon in Slate Meta. Clicking it focuses the input. - Control.
InputGroupInputorInputGroupTextarea: the input with its own border, ring and fill removed. Its padding tightens to 6px beside an addon. - Trailing addon.
align="inline-end"for units, keyboard hints andInputGroupButtons, pulled 5px toward the edge when it holds a button. - 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.
2% off when paid within 10 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> );}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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | 1px Field Stroke, transparent fill (Field Stroke at 30% in dark). |
| Focus visible | When 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. |
| Invalid | Any control inside with aria-invalid turns the group's border red with a 3px red ring at 20% (40% in dark). |
| Disabled | A 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-only | A readOnly input looks like rest and still focuses; pair it with a copy button. |
Behavior#
- Addons use CSS
order, soinline-startalways draws first andinline-endlast. 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.
InputGroupButtondefaults totype="button",variant="ghost"andsize="xs"(24px).icon-xsis a 24px square for icon actions.block-startandblock-endaddons 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 ash-9on auth screens.
Do and don't#
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>oraria-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 anaria-labeland a tooltip with the same words. - The 24px inline buttons meet the 24px minimum target. On touch-first screens raise the group to
h-9and useicon-sm. - Toggle buttons such as show password expose their state with
aria-pressed.
| Keys | Action |
|---|---|
| Tab | Moves to the control, then to each inline button in DOM order. Addon text is skipped. |
| Enter | Activates a focused inline button. |
Design tokens#
| Token | Used for |
|---|---|
--input | Group stroke; 30% fill in dark; 50% tint when disabled |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid border and 3px ring at 20% |
--muted-foreground | Addon text and icons |
--muted | Hover fill of ghost inline buttons |
--radius-lg | 10px group corners |
--radius | Inline 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
data-disabled | boolean | No default | Dims the addons along with a disabled control. |
className | string | No default | Merged 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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.