Input Group
A flexible component for grouping inputs with addons, buttons, and other elements. Built with Base UI and Tailwind CSS. Copy-paste ready.
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput aria-label="Search" placeholder="Search" type="search" />
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
);
}
Installation
pnpm dlx shadcn@latest add @cnippet/input-group
Usage
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"<InputGroup>
<InputGroupInput type="email" placeholder="Email" />
<InputGroupAddon>
<MailIcon />
</InputGroupAddon>
</InputGroup>Examples
With End Icon
Places a decorative icon in the trailing InputGroupAddon — useful for search, email, or URL inputs.
import { MailIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput aria-label="Email" placeholder="Email" type="email" />
<InputGroupAddon align="inline-end">
<MailIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
);
}
With Start Text
A static text prefix (e.g. https://) prepended to the input using InputGroupText for formatted entry fields.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Set your URL"
className="*:[input]:ps-0!"
placeholder="coss"
type="search"
/>
<InputGroupAddon>
<InputGroupText>i.cal.com/</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
With End Text
A static text suffix (e.g. @domain.com or .00) appended after the input value.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Choose a username"
placeholder="Choose a username"
type="text"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>@ui.cnippet.dev</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
With Start/End Text
Wraps the input with both a leading and trailing text label — common for currency inputs like $ / .00.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Enter your domain"
className="*:[input]:px-0!"
placeholder="coss"
type="text"
/>
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
With Tooltip
The trailing addon is an info icon button that triggers a Tooltip with additional context about the field.
"use client";
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Password"
placeholder="Password"
type="password"
/>
<InputGroupAddon align="inline-end">
<Popover>
<PopoverTrigger
openOnHover
render={
<Button
aria-label="Password requirements"
size="icon-xs"
variant="ghost"
/>
}
>
<InfoIcon />
</PopoverTrigger>
<PopoverPopup side="top" tooltipStyle>
<p>Min. 8 characters</p>
</PopoverPopup>
</Popover>
</InputGroupAddon>
</InputGroup>
);
}
With Icon Button
The addon contains a clickable icon button for a contextual action, such as copy, clear, or insert.
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useRef } from "react";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Tooltip,
TooltipPopup,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Particle() {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const inputRef = useRef<HTMLInputElement>(null);
return (
<InputGroup>
<InputGroupInput
aria-label="Url"
defaultValue="https://ui.cnippet.dev"
ref={inputRef}
type="text"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Copy"
onClick={() => {
if (inputRef.current) {
copyToClipboard(inputRef.current.value);
}
}}
size="icon-xs"
variant="ghost"
/>
}
>
{isCopied ? <CheckIcon /> : <CopyIcon />}
</TooltipTrigger>
<TooltipPopup>
<p>Copy to clipboard</p>
</TooltipPopup>
</Tooltip>
</InputGroupAddon>
</InputGroup>
);
}
With Button
A full button in the trailing addon, commonly used for submit actions inline with the input (e.g. "Subscribe").
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput placeholder="Type to search…" type="search" />
<InputGroupAddon align="inline-end">
<Button size="xs" variant="secondary">
Search
</Button>
</InputGroupAddon>
</InputGroup>
);
}
With Badge
A badge in the addon area shows supplementary metadata — such as a file type indicator or character count label.
import { Badge } from "@/components/ui/badge";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput placeholder="Type to search…" type="search" />
<InputGroupAddon align="inline-end">
<Badge variant="info">Badge</Badge>
</InputGroupAddon>
</InputGroup>
);
}
With Keyboard Shortcut
A KbdGroup in the trailing addon displays the keyboard shortcut that focuses or activates the input.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput placeholder="Search…" type="search" />
<InputGroupAddon align="inline-end">
<Kbd>⌘K</Kbd>
</InputGroupAddon>
</InputGroup>
);
}
With Inner Label
A small floating label rendered inside the addon area to clarify the input's purpose without an external label.
"use client";
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
id="email-1"
placeholder="team@ui.cnippet.dev"
type="email"
/>
<InputGroupAddon align="block-start">
<Label className="text-foreground" htmlFor="email-1">
Email
</Label>
<Popover>
<PopoverTrigger
className="ml-auto"
openOnHover
render={<Button className="-m-1" size="icon-xs" variant="ghost" />}
>
<InfoIcon />
</PopoverTrigger>
<PopoverPopup side="top" tooltipStyle>
<p>We'll use this to send you notifications</p>
</PopoverPopup>
</Popover>
</InputGroupAddon>
</InputGroup>
);
}
Sizes
Renders sm, default, and lg size variants of the input group to show scaling across different layout contexts.
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<>
<InputGroup>
<InputGroupInput
aria-label="Search"
placeholder="Search"
size="sm"
type="search"
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
aria-label="Search"
placeholder="Search"
size="lg"
type="search"
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
</>
);
}
Disabled
Sets the entire input group to a disabled state, preventing interaction with both the input and any addons.
import { ArrowRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput
aria-label="Subscribe to our newsletter"
disabled
placeholder="Your best email"
type="email"
/>
<InputGroupAddon align="inline-end">
<Button aria-label="Subscribe" disabled size="icon-xs" variant="ghost">
<ArrowRightIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
</InputGroup>
);
}
Loading
A spinner in the addon area signals that the input value is being validated or processed asynchronously.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
export default function Particle() {
return (
<InputGroup>
<InputGroupInput disabled placeholder="Searching…" type="search" />
<InputGroupAddon align="inline-end">
<Spinner />
</InputGroupAddon>
</InputGroup>
);
}
With Number Field
Replaces the standard input with a NumberField inside the group, gaining increment/decrement buttons with the addon styling.
import {
InputGroup,
InputGroupAddon,
InputGroupText,
} from "@/components/ui/input-group";
import {
NumberField,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<InputGroup>
<NumberField aria-label="Enter the amount" defaultValue={10}>
<NumberFieldInput className="text-left" />
</NumberField>
<InputGroupAddon>
<InputGroupText>€</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>EUR</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
With Textarea
Swaps the single-line input for a Textarea with a block-end addon for submit or action buttons below the text area.
"use client";
import { ArrowUpIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
import {
Tooltip,
TooltipPopup,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Particle() {
return (
<InputGroup>
<InputGroupTextarea placeholder="Ask, Search or Chat…" />
<InputGroupAddon align="block-end">
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<Button
aria-label="Add files"
className="rounded-full"
size="icon-sm"
variant="ghost"
/>
}
>
<PlusIcon />
</MenuTrigger>
}
/>
<TooltipPopup>Add files and more</TooltipPopup>
</Tooltip>
<MenuPopup align="start">
<MenuItem>Add photos & files</MenuItem>
<MenuItem>Create image</MenuItem>
<MenuItem>Thinking</MenuItem>
<MenuItem>Deep research</MenuItem>
</MenuPopup>
</Menu>
<InputGroupText className="ml-auto">78% used</InputGroupText>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Send"
className="rounded-full"
size="icon-sm"
variant="default"
>
<ArrowUpIcon />
</Button>
}
/>
<TooltipPopup>Send</TooltipPopup>
</Tooltip>
</InputGroupAddon>
</InputGroup>
);
}
With Currency Select
A leading Select dropdown for currency combined with a numeric input and a trailing unit label — ideal for pricing or payment fields.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const currencies = [
{ label: "USD", value: "usd" },
{ label: "EUR", value: "eur" },
{ label: "GBP", value: "gbp" },
];
export default function Particle() {
return (
<InputGroup>
<InputGroupAddon>
<Select defaultValue="usd" items={currencies}>
<SelectTrigger className="h-full rounded-none border-0 border-e bg-transparent shadow-none focus-visible:z-10">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{currencies.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
</InputGroupAddon>
<InputGroupInput
aria-label="Amount"
inputMode="decimal"
placeholder="0.00"
type="text"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>per unit</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
Newsletter Subscribe
An email input with a ghost arrow-right button in the trailing addon for a compact inline newsletter subscription form.
Stay in the loop
Get weekly updates. No spam, unsubscribe anytime.
import { ArrowRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<p className="font-medium text-sm">Stay in the loop</p>
<p className="text-muted-foreground text-xs">
Get weekly updates. No spam, unsubscribe anytime.
</p>
<InputGroup>
<InputGroupInput
aria-label="Email address"
placeholder="you@example.com"
type="email"
/>
<InputGroupAddon align="inline-end">
<Button aria-label="Subscribe" size="icon-xs" variant="ghost">
<ArrowRightIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
</InputGroup>
</div>
);
}
With Clear Button
A text input with an × ghost button addon that appears only when the field has content and clears it on click.
"use client";
import { XIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
const [value, setValue] = useState("design-system");
return (
<InputGroup>
<InputGroupInput
aria-label="Tag name"
onChange={(e) => setValue(e.target.value)}
placeholder="Enter a tag…"
type="text"
value={value}
/>
{value && (
<InputGroupAddon align="inline-end">
<Button
aria-label="Clear"
onClick={() => setValue("")}
size="icon-xs"
variant="ghost"
>
<XIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
)}
</InputGroup>
);
}
Chat Input With Send
A message input with a send icon button that shows a loading spinner while the async send operation completes, then clears the field.
"use client";
import { SendIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
export default function Particle() {
const [loading, setLoading] = useState(false);
const [value, setValue] = useState("");
const handleSend = async () => {
if (!value.trim()) return;
setLoading(true);
await new Promise((r) => setTimeout(r, 1200));
setLoading(false);
setValue("");
};
return (
<InputGroup className="w-full max-w-sm">
<InputGroupInput
aria-label="Message"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message…"
type="text"
value={value}
/>
<InputGroupAddon align="inline-end">
<Button
aria-label="Send message"
loading={loading}
onClick={handleSend}
size="icon-xs"
variant="ghost"
>
{!loading && <SendIcon aria-hidden="true" />}
</Button>
</InputGroupAddon>
</InputGroup>
);
}
Textarea With Character Count
An InputGroupTextarea with a character countdown in the block-end addon — the count turns amber at 20 remaining and red when over the limit.
"use client";
import { useState } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group";
const MAX = 280;
export default function Particle() {
const [value, setValue] = useState("");
const remaining = MAX - value.length;
const isNear = remaining <= 20;
const isOver = remaining < 0;
return (
<InputGroup className="w-full max-w-sm">
<InputGroupTextarea
aria-label="Post content"
maxLength={MAX + 20}
onChange={(e) => setValue(e.target.value)}
placeholder="What's on your mind?"
rows={3}
value={value}
/>
<InputGroupAddon align="block-end">
<InputGroupText
className={
isOver
? "text-destructive-foreground"
: isNear
? "text-warning"
: "text-muted-foreground"
}
>
{remaining}
</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
}
On This Page

