Input Group
A shadcn-style input group component built with Ark UI primitives.
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupDemo = () => (
<InputGroup className="w-full max-w-xs">
<InputGroupInput aria-label="Search" placeholder="Search" type="search" />
<InputGroupAddon>
<SearchIcon aria-hidden="true" className="size-4" />
</InputGroupAddon>
</InputGroup>
);
export default InputGroupDemo;
Installation
npx shadcn@latest add @ark-cn/input-groupInstall the dependency required by this primitive:
npm install @ark-ui/react class-variance-authorityCopy the component source into your app:
TSXcomponents/ui/input-group.tsx
"use client";
import { ark } from "@ark-ui/react/factory";
import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export type InputGroupProps = ComponentProps<typeof ark.div>;
export const InputGroup = ({ className, ...props }: InputGroupProps) => (
<ark.div
data-slot="input-group"
className={cn(
"group/input-group relative flex w-full min-w-0 flex-row flex-wrap items-stretch overflow-hidden rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-within:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-within:has-aria-invalid:border-destructive/64 has-focus-within:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-within:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-within,[aria-invalid]]:shadow-none has-focus-within:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-within:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
"has-data-[slot=input-group-textarea]:flex-col has-[[data-slot=input-group-addon][data-align=block-start]]:flex-col has-[[data-slot=input-group-addon][data-align=block-end]]:flex-col",
className,
)}
{...props}
/>
);
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
{
defaultVariants: {
align: "inline-start",
},
variants: {
align: {
"block-end":
"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
"block-start":
"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
"inline-end":
"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
"inline-start":
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
},
},
},
);
export type InputGroupAddonProps = ComponentProps<typeof ark.div> &
VariantProps<typeof inputGroupAddonVariants>;
export const InputGroupAddon = ({
className,
align = "inline-start",
...props
}: InputGroupAddonProps) => {
return (
<ark.div
className={cn(inputGroupAddonVariants({ align }), className)}
data-align={align}
data-slot="input-group-addon"
onMouseDown={(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const isInteractive = target.closest(
"button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']",
);
if (isInteractive) return;
e.preventDefault();
const parent = e.currentTarget.parentElement;
const input = parent?.querySelector<
HTMLInputElement | HTMLTextAreaElement
>("input, textarea");
if (input && !parent?.querySelector("input:focus, textarea:focus")) {
input.focus();
}
}}
{...props}
/>
);
};
export type InputGroupTextProps = ComponentProps<typeof ark.span>;
export const InputGroupText = ({
className,
...props
}: InputGroupTextProps) => (
<ark.span
data-slot="input-group-text"
className={cn(
"text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
const inputGroupFieldSize = {
default: "",
lg: "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",
sm: "h-7.5 leading-7.5 sm:h-6.5 sm:leading-6.5",
} as const;
export type InputGroupInputProps = Omit<
ComponentProps<typeof ark.input>,
"size"
> & {
size?: keyof typeof inputGroupFieldSize | number;
};
export const InputGroupInput = ({
className,
size = "default",
...props
}: InputGroupInputProps) => (
<ark.input
data-slot="input-group-input"
data-size={typeof size === "string" ? size : undefined}
className={cn(
"order-2 min-h-8.5 min-w-0 flex-1 rounded-none border-0 bg-transparent px-[calc(--spacing(3)-1px)] leading-8.5 shadow-none outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed sm:min-h-7.5 sm:leading-7.5",
size === "sm" && inputGroupFieldSize.sm,
size === "lg" && inputGroupFieldSize.lg,
props.type === "search" &&
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
props.type === "file" &&
"text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm",
className,
)}
size={typeof size === "number" ? size : undefined}
{...props}
/>
);
export type InputGroupTextareaProps = Omit<
ComponentProps<typeof ark.textarea>,
"size"
> & {
size?: keyof typeof inputGroupFieldSize;
};
export const InputGroupTextarea = ({
className,
size = "default",
...props
}: InputGroupTextareaProps) => (
<ark.textarea
data-slot="input-group-textarea"
data-size={size}
className={cn(
"order-2 min-h-16 w-full min-w-0 flex-1 resize-none rounded-none border-0 bg-transparent px-[calc(--spacing(3)-1px)] py-2.5 shadow-none outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed sm:py-2 sm:text-sm",
size === "sm" && "min-h-14 py-2 text-sm",
size === "lg" && "min-h-20 py-3 text-base sm:text-sm",
className,
)}
{...props}
/>
);
export { inputGroupAddonVariants };
Update import aliases to match your project setup.
Usage
import * as InputGroup from "@/components/ui/input-group"Read exported parts in src/components/ui/input-group.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Disabled
import { ArrowRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupDisabled = () => {
return (
<InputGroup className="w-full max-w-xs">
<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>
);
};
export default InputGroupDisabled;
Start Text
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
const InputGroupStartText = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Set your URL"
className="*:[input]:ps-0!"
placeholder="ark-cn"
type="search"
/>
<InputGroupAddon>
<InputGroupText>i.cal.com/</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupStartText;
End Text
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
const InputGroupEndText = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Choose a username"
placeholder="Choose a username"
type="text"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>@example.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupEndText;
Start + End Text
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
const InputGroupStartEndText = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Enter your domain"
className="*:[input]:px-0!"
placeholder="ark-cn"
type="text"
/>
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupStartEndText;
End Icon
import { MailIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupEndIcon = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput aria-label="Email" placeholder="Email" type="email" />
<InputGroupAddon align="inline-end">
<MailIcon aria-hidden="true" className="size-4" />
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupEndIcon;
Icon Button
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ClipboardIndicator,
ClipboardRoot,
ClipboardTrigger,
} from "@/components/ui/clipboard";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip";
const InputGroupIconButton = () => {
const [value, setValue] = useState("");
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Url"
defaultValue="https://ark-ui.com"
value={value}
onChange={(e) => setValue(e.target.value)}
type="text"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<ClipboardRoot value={value}>
<TooltipTrigger asChild>
<ClipboardTrigger asChild>
<Button aria-label="Copy" size="icon-xs" variant="ghost">
<ClipboardIndicator copied={<CheckIcon className="size-4" />}>
<CopyIcon className="size-4" />
</ClipboardIndicator>
</Button>
</ClipboardTrigger>
</TooltipTrigger>
</ClipboardRoot>
<TooltipPopup>
<p>Copy to clipboard</p>
</TooltipPopup>
</Tooltip>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupIconButton;
Button
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupButton = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput placeholder="Type to search…" type="search" />
<InputGroupAddon align="inline-end">
<Button size="xs" variant="secondary">
Search
</Button>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupButton;
Badge
import { Badge } from "@/components/ui/badge";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupBadge = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput placeholder="Type to search…" type="search" />
<InputGroupAddon align="inline-end">
<Badge variant="info">Badge</Badge>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupBadge;
Keyboard Shortcut
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
const InputGroupKeyboardShortcut = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput placeholder="Search…" type="search" />
<InputGroupAddon align="inline-end">
<Kbd>⌘K</Kbd>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupKeyboardShortcut;
Tooltip
import { InfoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip";
const InputGroupTooltip = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Password"
placeholder="Password"
type="password"
/>
<InputGroupAddon align="inline-end">
<Tooltip positioning={{ placement: "top" }}>
<TooltipTrigger asChild>
<Button
aria-label="Password requirements"
size="icon-xs"
variant="ghost"
>
<InfoIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipPopup>
<p>Min. 8 characters</p>
</TooltipPopup>
</Tooltip>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupTooltip;
Inner Label
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";
const InputGroupInnerLabel = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput id="email-1" placeholder="team@coss.com" type="email" />
<InputGroupAddon align="block-start">
<Label className="text-foreground" htmlFor="email-1">
Email
</Label>
<Popover
positioning={{
placement: "top",
}}
>
<PopoverTrigger asChild>
<Button className="-m-1" size="icon-xs" variant="ghost">
<InfoIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverPopup>
<p>We'll use this to send you notifications</p>
</PopoverPopup>
</Popover>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupInnerLabel;
Loading
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
const InputGroupLoading = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput disabled placeholder="Searching…" type="search" />
<InputGroupAddon align="inline-end">
<Spinner className="size-4" />
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupLoading;
Small Size
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupSmallSize = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Search"
placeholder="Search"
size="sm"
type="search"
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" className="size-4" />
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupSmallSize;
Large Size
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
const InputGroupLargeSize = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupInput
aria-label="Search"
placeholder="Search"
size="lg"
type="search"
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" className="size-5" />
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupLargeSize;
Textarea
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";
const InputGroupTextareaDemo = () => {
return (
<InputGroup className="w-full max-w-xs">
<InputGroupTextarea placeholder="Ask, Search or Chat…" />
<InputGroupAddon align="block-end">
<Menu>
<Tooltip>
<TooltipTrigger>
<MenuTrigger asChild>
<Button
aria-label="Add files"
className="rounded-full"
size="icon-sm"
variant="ghost"
>
<PlusIcon className="size-4" />
</Button>
</MenuTrigger>
</TooltipTrigger>
<TooltipPopup>Add files and more</TooltipPopup>
</Tooltip>
<MenuPopup>
<MenuItem value="add-photos-and-files">
Add photos & files
</MenuItem>
<MenuItem value="create-image">Create image</MenuItem>
<MenuItem value="thinking">Thinking</MenuItem>
<MenuItem value="deep-research">Deep research</MenuItem>
</MenuPopup>
</Menu>
<InputGroupText className="ml-auto">78% used</InputGroupText>
<Tooltip>
<TooltipTrigger>
<Button
aria-label="Send"
className="rounded-full"
size="icon-sm"
variant="default"
>
<ArrowUpIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipPopup>Send</TooltipPopup>
</Tooltip>
</InputGroupAddon>
</InputGroup>
);
};
export default InputGroupTextareaDemo;
API reference
This component is an ark-cn composition. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
InputGroupAddon
| Prop | Type | Description |
|---|---|---|
| align? | "inline-start" | "inline-end" | "block-start" | "block-end" | Placement of the addon region. |
InputGroupInput
| Prop | Type | Description |
|---|---|---|
| size? | "sm" | "default" | "lg" | number | Field height token inside the group. |
InputGroupTextarea
| Prop | Type | Description |
|---|---|---|
| size? | "sm" | "default" | "lg" | Min-height/padding for grouped textarea. |