Skip to content

Input Group

An input with icons, text, buttons or keyboard hints attached, bordered, focused and validated as one control.

SourceRegistry
⌘K
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

https://
  1. 1GroupOne bordered field that owns the focus halo, so the addons and the input read as a single control.
  2. 2Leading addonText or an icon before the input: a protocol, a currency, a search icon. Clicking it focuses the input.
  3. 3ControlThe input or textarea itself, borderless inside the group.
  4. 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.json

Usage

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.

.prfct.dev
$
USD
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.

@
@
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.

Enter to send · Shift + Enter for a new line
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.

webhook-payload.json
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.

.prfct.dev
DoText addons carry the fixed parts; people type only the variable one.
Don’tAsking for the full URL invites typos in parts people shouldn’t have to type.

Accessibility

  • One control, one name. Label the InputGroupInput with a FieldLabel and htmlFor/id, or aria-label. Addon text like USD is 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. InputGroupButton is a real button with type="button" by default, so it never submits a form by accident.
  • Shortcuts. A Kbd hint is visual only. Make the shortcut discoverable elsewhere too, such as in a tooltip or a help page.
KeyBehavior
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.

PropTypeDefault
align

Before or after the control on its line, or above or below it at full width.

"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"

InputGroupButton

Renders a Button sized for the group. Accepts all Button props; size takes the group's own sizes, listed below.

PropTypeDefault
size

24 or 28px tall; icon sizes are square.

"xs" | "sm" | "icon-xs" | "icon-sm""xs"
variant

Any Button variant. Use default for a primary action such as Send.

Button variant"ghost"
type

Defaults to button so it never submits a form by accident.

"button" | "submit" | "reset""button"

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.