Input Group
A composable input shell: icons, text prefixes, kbd hints and buttons inside one field. 3 variants, 3 sizes, 3 tones; focus and invalid state lift from the control to the group border.
import { Search, X } from "lucide-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export default function InputGroupDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput placeholder="Search components" aria-label="Search" />
<InputGroupAddon align="inline-end">
<InputGroupButton aria-label="Clear search">
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup variant="soft" size="sm">
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="ai2.design" aria-label="Site URL" />
</InputGroup>
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/input-groupDependencies, the @ai2/button component, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1Input Group renders its buttons through the ai2 Button, so add the Button component first.
Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/input-group.tsx"use client"
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const inputGroupVariants = cva(
"group/input-group relative flex w-full min-w-0 items-center outline-none transition-[color,box-shadow] duration-(--motion-fast) has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:disabled]:pointer-events-none has-[[data-slot=input-group-control]:disabled]:opacity-50 has-[[data-slot=input-group-control][aria-invalid=true]]:border-danger has-[>textarea]:h-auto has-[>textarea]:items-start has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:items-stretch has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:items-stretch has-[>[data-align=inline-start]]:[&>[data-slot=input-group-control]]:ps-2 has-[>[data-align=inline-end]]:[&>[data-slot=input-group-control]]:pe-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none",
{
variants: {
variant: {
outline: "border border-field-border bg-transparent shadow-xs dark:bg-input/30",
soft: "border border-transparent bg-surface-3 dark:bg-input/50",
ghost: "border border-transparent bg-transparent hover:bg-surface-3",
},
size: {
sm: "h-8 rounded-md [&>[data-slot=input-group-control]]:text-sm",
md: "h-9 rounded-lg [&>[data-slot=input-group-control]]:text-sm",
lg: "h-10 rounded-lg [&>[data-slot=input-group-control]]:text-base md:[&>[data-slot=input-group-control]]:text-sm",
},
tone: {
neutral: "",
success:
"border-success has-[[data-slot=input-group-control]:focus-visible]:border-success has-[[data-slot=input-group-control]:focus-visible]:ring-success/20 dark:has-[[data-slot=input-group-control]:focus-visible]:ring-success/40",
danger:
"border-danger has-[[data-slot=input-group-control]:focus-visible]:border-danger has-[[data-slot=input-group-control]:focus-visible]:ring-danger/20 dark:has-[[data-slot=input-group-control]:focus-visible]:ring-danger/40",
},
},
defaultVariants: { variant: "outline", size: "md", tone: "neutral" },
}
)
interface InputGroupProps
extends React.ComponentProps<"div">,
VariantProps<typeof inputGroupVariants> {}
function InputGroup({ className, variant, size, tone, ...props }: InputGroupProps) {
return (
<div
role="group"
data-slot="input-group"
data-variant={variant ?? "outline"}
data-tone={tone ?? "neutral"}
className={cn(inputGroupVariants({ variant, size, tone, className }))}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex select-none items-center justify-center gap-2 whitespace-nowrap text-sm text-muted-foreground",
{
variants: {
align: {
"inline-start": "order-first ps-3",
"inline-end": "order-last pe-3",
"block-start": "order-first w-full justify-start px-3 pt-2",
"block-end": "order-last w-full justify-start px-3 pb-2",
},
},
defaultVariants: { align: "inline-start" },
}
)
interface InputGroupAddonProps
extends React.ComponentProps<"div">,
VariantProps<typeof inputGroupAddonVariants> {}
function InputGroupAddon({
className,
align = "inline-start",
onClick,
...props
}: InputGroupAddonProps) {
return (
<div
data-slot="input-group-addon"
data-align={align}
onClick={(event) => {
// Clicking passive addon chrome focuses the control; real buttons keep their job.
if (!(event.target as HTMLElement).closest("button")) {
event.currentTarget.parentElement
?.querySelector<HTMLElement>("[data-slot=input-group-control]")
?.focus()
}
onClick?.(event)
}}
className={cn(inputGroupAddonVariants({ align, className }))}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="input-group-text"
className={cn("flex items-center gap-1.5 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function InputGroupButton({
className,
variant = "ghost",
size = "icon-xs",
type = "button",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="input-group-button"
variant={variant}
size={size}
type={type}
className={cn("shadow-none", className)}
{...props}
/>
)
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<input
data-slot="input-group-control"
className={cn(
"h-full w-full flex-1 bg-transparent px-3 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="input-group-control"
className={cn(
"field-sizing-content min-h-16 w-full flex-1 resize-none bg-transparent px-3 py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupButton,
InputGroupInput,
InputGroupTextarea,
inputGroupVariants,
type InputGroupProps,
type InputGroupAddonProps,
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the surface colors will be missing.
Usage
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Search } from "lucide-react"
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput placeholder="Search components" />
</InputGroup>The group carries the appearance; the inner control is transparent. Set variant, size and tone on InputGroup, and native input props like placeholder, disabled and aria-invalid on InputGroupInput.
Examples
Icon and button
Clicking the leading icon focuses the input; the trailing InputGroupButton keeps its own click handler.
Text prefix and suffix
Kbd hint
Textarea with block addon
align="block-end" turns the addon into a full-width row under the control and switches the group to auto height. The textarea grows with its content.
Block start addon
align="block-start" is the fourth position: a full-width row above the control, useful for headers inside the field. Together with inline-start, inline-end and block-end it completes the align axis.
Variants
Sizes
Tones
The tone axis colors the group border and focus ring. Setting aria-invalid on the inner control alone also lifts the danger border to the group.
Disabled
Set disabled on the inner control. The has-[] selector dims the whole group and blocks pointer events, so addons and buttons go quiet with the field.
Props
InputGroup
InputGroup also accepts every native <div> prop. InputGroupInput and InputGroupTextarea accept every native <input> and <textarea> prop respectively.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "outline" | "soft" | "ghost" | "outline" | Visual style of the group shell, matching the Input variants. |
size | "sm" | "md" | "lg" | "md" | Height, radius and control typography of the group. The inner control inherits the text scale. |
tone | "neutral" | "success" | "danger" | "neutral" | Semantic validation color: success and danger recolor the group border and focus ring. |
InputGroupAddon
| Prop | Type | Default | Description |
|---|---|---|---|
align | "inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" | Where the addon sits: inline before or after the control, or as a full-width row above or below it. |
InputGroupButton
InputGroupButton forwards every ai2 Button prop; only the defaults differ.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | Button variant | "ghost" | Forwarded to the ai2 Button. Ghost keeps the addon quiet inside the field. |
size | Button size | "icon-xs" | Forwarded to the ai2 Button. icon-xs is a square size-7 button that fits the md group height. |
ai2 Input Group: icons, text and buttons inside a react input
The ai2 Input Group is a shadcn-compatible composition of six parts for React, styled with Tailwind CSS v4 on the shared ai2 tokens. The outer InputGroup carries the field appearance with 3 variants (outline, soft, ghost), 3 sizes (sm, md, lg) and 3 tones (neutral, success, danger), while InputGroupInput or InputGroupTextarea render transparent controls inside it and InputGroupAddon places icons, text, kbd hints or buttons around them.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Input Group?
It is a role="group" wrapper driven by inputGroupVariants, an exported cva definition with the same variant, size and tone axes as the ai2 Input. The inner control is a bare transparent input or textarea tagged data-slot="input-group-control", and the group watches it with has-[] selectors: focus, disabled and aria-invalid on the control restyle the group border and ring, so the whole field reacts as one control.
Addons are positioned by the align prop: inline-start and inline-end sit beside the control, block-start and block-end become full-width rows above or below it, which is how textarea toolbars are built. Clicking passive addon chrome focuses the control; buttons inside addons keep their own click behavior.
Why use it
- One focus ring for the whole field: Focus, invalid and disabled state lift from the inner control to the group border via has-[] selectors, so an icon plus input plus button reads as a single control to users.
- Same three axes as Input: outline, soft and ghost variants, sm, md and lg sizes, neutral, success and danger tones. A grouped field lines up pixel for pixel next to a plain ai2 Input in the same form.
- Four addon positions: inline-start and inline-end for icons, prefixes and buttons; block-start and block-end for full-width rows like character counters and textarea toolbars.
- Built on the ai2 Button: InputGroupButton is the real ai2 Button with ghost and icon-xs defaults, so any Button variant, tone or size works inside the field without new APIs.
- Click-to-focus addons: Clicking passive addon chrome, like a search icon or a https:// prefix, focuses the inner control instead of swallowing the click. Real buttons keep their job.
Features
- shadcn registry install: One command adds the component, the ai2 Button dependency and the @ai2/tokens theme to your project.
- Six composable parts: InputGroup, InputGroupInput, InputGroupTextarea, InputGroupAddon, InputGroupText and InputGroupButton. Compose only what the field needs.
- 3 variants, 3 sizes, 3 tones: outline (default), soft and ghost, each in sm, md and lg with matched height, radius and control typography, plus neutral, success and danger tones on the group border and ring.
- State lifting via has-[] selectors: focus-visible, disabled and aria-invalid on the inner control restyle the group automatically. No JavaScript state mirroring.
- Textarea aware: InputGroupTextarea uses field-sizing-content to grow with input, and the group switches to top alignment and auto height when it contains a textarea or a block addon.
- Icon-family tolerant: The group sizes lucide <svg> icons to size-4 and remixicon <i> glyphs to text-base, so both families sit correctly inside addons.
Production tips
- Reach for it only when the field has chrome: A bare text field is still the plain ai2 Input. Switch to Input Group when you need an icon, prefix, kbd hint or button inside the same border.
- Keep aria-invalid on the control: Set aria-invalid on InputGroupInput or InputGroupTextarea, not on the group. The has-[] selector lifts the danger border to the group for you.
- Label icon-only buttons: InputGroupButton defaults to a square icon-xs ghost button, so an icon-only trigger needs an aria-label to keep the field accessible.
- Use block addons for textarea toolbars: align="block-end" turns the addon into a full-width row under the textarea, the right place for counters, hints and send or mic buttons.
- Push trailing content with ml-auto: Inside a block addon, add ml-auto to the last button to split a leading counter from trailing actions, as in the textarea example above.
Works with the rest of ai2
Input Group extends the form kit. Wrap it in an ai2 Field for label, description and error placement, drop an ai2 Kbd into a trailing addon for shortcut hints, and use the ai2 Button API directly through InputGroupButton.
For a plain field without addons, stay on the Input or Textarea, which share the same variant, size and tone language. For file uploads, use the FileInput component instead of grouping a file input.