Input Group
An input with icons, text, buttons or keyboard hints attached, bordered, focused and validated as one control.
import { SearchIcon } from "lucide-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Kbd } from "@/components/ui/kbd"
export default function InputGroupDemo() {
return (
<InputGroup className="w-72">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="Search issues…"
aria-label="Search issues"
/>
<InputGroupAddon align="inline-end">
<Kbd>⌘K</Kbd>
</InputGroupAddon>
</InputGroup>
)
}Anatomy
- 1GroupOne bordered field that owns the focus halo, so the addons and the input read as a single control.
- 2Leading addonText or an icon before the input: a protocol, a currency, a search icon. Clicking it focuses the input.
- 3ControlThe input or textarea itself, borderless inside the group.
- 4Trailing addonA button, a unit or a keyboard hint after the input.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/input-group.jsonUsage
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search…" />
</InputGroup>Inside a group, use InputGroupInput and InputGroupTextarea — never a plain Input. The group owns the border, fill, focus halo and invalid state; the inner control is deliberately bare.
Examples
Text addons
Prefixes and suffixes carry units and fixed parts of a value — a currency, a domain — so people type only what's variable. Wrap text in InputGroupText.
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export default function InputGroupTextAddons() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="input-group-subdomain">Workspace URL</FieldLabel>
<InputGroup>
<InputGroupInput
id="input-group-subdomain"
defaultValue="acme"
className="text-right"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>.prfct.dev</InputGroupText>
</InputGroupAddon>
</InputGroup>
</Field>
<Field>
<FieldLabel htmlFor="input-group-price">Monthly budget</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="input-group-price"
inputMode="decimal"
defaultValue="2,400.00"
className="tabular-nums"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>USD</InputGroupText>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
)
}Buttons
InputGroupButton is a compact ghost Button sized to sit inside the field: copy a value, reveal a password, clear a query. Icon-only buttons need an aria-label; toggles like Show password also report aria-pressed.
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
export default function InputGroupButtons() {
const [visible, setVisible] = React.useState(false)
const [copied, setCopied] = React.useState(false)
const key = "sk_live_51Mz9aQ8dK2x"
React.useEffect(() => {
if (!copied) return
const timer = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(timer)
}, [copied])
async function copy() {
try {
await navigator.clipboard.writeText(key)
setCopied(true)
} catch {
// The clipboard can be denied; the key stays in the field to select.
}
}
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="input-group-key">API key</FieldLabel>
<InputGroup>
<InputGroupInput
id="input-group-key"
readOnly
value={key}
className="font-mono"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy API key"}
onClick={copy}
>
{copied ? (
<CheckIcon className="text-success-11" />
) : (
<CopyIcon />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Field>
<FieldLabel htmlFor="input-group-password">Password</FieldLabel>
<InputGroup>
<InputGroupInput
id="input-group-password"
type={visible ? "text" : "password"}
defaultValue="parallel-lines-meet"
autoComplete="current-password"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
onClick={() => setVisible((value) => !value)}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
)
}Status
Addons can report what's happening to the value — checking availability with a Spinner, then confirming with an icon. Always repeat the status in text below the field; an icon alone isn't enough.
Checking availability…
ada.lovelace is available.
import { CircleCheckIcon } from "lucide-react"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
export default function InputGroupStatus() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="input-group-checking">Username</FieldLabel>
<InputGroup>
<InputGroupAddon>
<span className="text-muted-foreground">@</span>
</InputGroupAddon>
<InputGroupInput id="input-group-checking" defaultValue="ada" />
<InputGroupAddon align="inline-end">
<Spinner label="Checking availability" />
</InputGroupAddon>
</InputGroup>
<FieldDescription>Checking availability…</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="input-group-available">Username</FieldLabel>
<InputGroup>
<InputGroupAddon>
<span className="text-muted-foreground">@</span>
</InputGroupAddon>
<InputGroupInput
id="input-group-available"
defaultValue="ada.lovelace"
/>
<InputGroupAddon align="inline-end">
<CircleCheckIcon className="text-success-11" aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
<FieldDescription className="text-success-11">
ada.lovelace is available.
</FieldDescription>
</Field>
</FieldGroup>
)
}Composer
With InputGroupTextarea and a block-end addon, the group becomes a message composer: the field grows with the text while attachments, hints and the send action stay pinned below it.
import { ArrowUpIcon, AtSignIcon, PaperclipIcon } from "lucide-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
export default function InputGroupComposer() {
return (
<InputGroup className="w-full max-w-lg">
<InputGroupTextarea
placeholder="Reply to #design-reviews…"
aria-label="Message"
className="min-h-20"
/>
<InputGroupAddon align="block-end" className="gap-1">
<InputGroupButton size="icon-sm" aria-label="Attach a file">
<PaperclipIcon />
</InputGroupButton>
<InputGroupButton size="icon-sm" aria-label="Mention someone">
<AtSignIcon />
</InputGroupButton>
<InputGroupText className="ml-auto text-xs">
Enter to send · Shift + Enter for a new line
</InputGroupText>
<InputGroupButton
size="icon-sm"
variant="default"
aria-label="Send message"
className="ml-2 rounded-full"
>
<ArrowUpIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}Block addons
block-start addons sit above the control, full width — a file name above a code editor, a toolbar above a note. Add border-b to separate them.
import { CodeIcon } from "lucide-react"
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
export default function InputGroupBlockAddon() {
return (
<InputGroup className="w-full max-w-lg">
<InputGroupAddon align="block-start" className="border-b pb-2.5">
<CodeIcon />
<InputGroupText className="font-mono text-xs text-foreground">
webhook-payload.json
</InputGroupText>
</InputGroupAddon>
<InputGroupTextarea
aria-label="Webhook payload"
className="min-h-28 font-mono text-code"
defaultValue={'{\n "event": "invoice.paid",\n "amount": 4900\n}'}
/>
</InputGroup>
)
}Guidelines
When to use
- Units, prefixes and suffixes that are part of the value:
$,USD,.prfct.dev,https://. - Actions that operate on the value itself: copy, reveal, clear, submit a search.
- Signals about the value: a leading search icon, a validation status, a keyboard shortcut hint.
When not to use
- Actions that aren't about the value, like Save or Cancel — put a Button next to the field instead.
- More than two addons on one side. A crowded field is hard to scan and leaves little room to type.
- Multi-line rich editing with formatting — use a dedicated editor.
Keep addons honest
A leading icon should clarify what the field is for, not decorate it. If it's interactive, it must be an InputGroupButton with a label — a clickable-looking icon that does nothing is a trap.
Accessibility
- One control, one name. Label the
InputGroupInputwith aFieldLabelandhtmlFor/id, oraria-label. Addon text likeUSDis not part of the name — mention the unit in the label or description when it matters. - Grouping. The group and each addon render
role="group"; clicking an addon's empty space focuses the input. - Focus and state. The whole group shows the brand focus halo while its control is focused, by keyboard or by pointer, and turns red when the control has
aria-invalid. - Buttons.
InputGroupButtonis a real button withtype="button"by default, so it never submits a form by accident. - Shortcuts. A
Kbdhint is visual only. Make the shortcut discoverable elsewhere too, such as in a tooltip or a help page.
| Key | Behavior |
|---|---|
Tab | Moves focus to the input, then to each button in the group. |
EnterSpace | Activates a focused InputGroupButton. |
API reference
InputGroup
Renders a <div role="group"> that draws the border, fill and focus state. Accepts all div props.
InputGroupAddon
Renders a <div role="group"> that holds icons, text, buttons or Kbd hints.
InputGroupButton
Renders a Button sized for the group. Accepts all Button props; size takes the group's own sizes, listed below.
InputGroupText
Renders a <span> for text and icons in muted-foreground.
InputGroupInput
Renders a borderless Base UI Input. Accepts all native input props.
InputGroupTextarea
Renders a borderless <textarea> that grows with its content. Accepts all native textarea props.