One bordered field that wraps an input or textarea with icon, text, button and keyboard-hint addons at either end or above and below.
"use client";
import * as React from "react";
import { ArrowUp, AtSign, Check, Copy, Globe, Search } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/components/ballmac/input-group";
export default function InputGroupDemo() {
const [copied, setCopied] = React.useState(false);
return (
<div className="grid w-full max-w-sm gap-3">
<InputGroup aria-label="Search">
<InputGroupAddon>
<Search aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput aria-label="Search projects" placeholder="Search projects" />
<InputGroupAddon align="inline-end">
<span className="text-xs tabular-nums">12 results</span>
</InputGroupAddon>
</InputGroup>
<InputGroup aria-label="Website address">
<InputGroupAddon>
<InputGroupText>
<Globe aria-hidden="true" /> https://
</InputGroupText>
</InputGroupAddon>
<InputGroupInput aria-label="Domain" defaultValue="acme" />
<InputGroupAddon align="inline-end">
<InputGroupText>.example.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup aria-label="Share link">
<InputGroupInput aria-label="Share link" readOnly value="acme.example/r/8f3k" />
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy link"}
onClick={() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup aria-label="Message">
<InputGroupTextarea aria-label="Message" placeholder="Reply to the thread…" rows={2} />
<InputGroupAddon align="block-end">
<InputGroupButton size="icon-sm" variant="ghost" aria-label="Mention someone">
<AtSign aria-hidden="true" />
</InputGroupButton>
<InputGroupButton size="sm" variant="default" className="ms-auto">
Send <ArrowUp aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/input-groupInstall the dependencies.
$ pnpm add class-variance-authorityCopy the source into your project.
components/ballmac/input-group.tsx// Ballmac UI: Input Group. https://ui.ballmac.com/components/input-group // Based on shadcn/ui Input Group (MIT, Copyright (c) 2023 shadcn), adding a size scale, invalid state, and click-to-focus addons. "use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; type InputGroupProps = React.ComponentProps<"div"> & { /** Accessible name for the group, for example "Website address". */ "aria-label"?: string; }; /** * A bordered field that holds one input or textarea plus addons: icons, text, buttons, keyboard hints. * The group draws the border, focus ring and invalid state; the control inside is borderless. */ function InputGroup({ className, ...props }: InputGroupProps) { return ( <div data-slot="input-group" role="group" className={cn( "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input bg-background shadow-xs transition-[color,border-color,box-shadow] duration-150 dark:bg-input/30", "has-[>textarea]:h-auto", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col", "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][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control][aria-invalid=true]]:ring-destructive/20", "has-[[data-slot=input-group-control]:disabled]:opacity-50", className, )} {...props} /> ); } const addonVariants = cva( "flex cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-sm", { variants: { align: { "inline-start": "order-first ps-3 has-[>button]:-ms-1.5", "inline-end": "order-last pe-3 has-[>button]:-me-1.5", "block-start": "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5", "block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5", }, }, defaultVariants: { align: "inline-start" }, }, ); type InputGroupAddonProps = React.ComponentProps<"div"> & VariantProps<typeof addonVariants> & { /** Where the addon sits. Inline addons flank the control; block addons stack above or below it (for textareas). */ align?: "inline-start" | "inline-end" | "block-start" | "block-end"; }; function InputGroupAddon({ className, align = "inline-start", onClick, ...props }: InputGroupAddonProps) { return ( <div data-slot="input-group-addon" data-align={align} className={cn(addonVariants({ align }), className)} onClick={(event) => { onClick?.(event); if (event.defaultPrevented || (event.target as HTMLElement).closest("button, a")) return; event.currentTarget.parentElement ?.querySelector<HTMLElement>("[data-slot=input-group-control]") ?.focus(); }} {...props} /> ); } const buttonVariants = cva("flex items-center gap-2 text-sm shadow-none", { variants: { size: { xs: "h-6 gap-1 rounded-[5px] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", "icon-xs": "size-6 rounded-[5px] p-0", "icon-sm": "size-7 rounded-md p-0", }, variant: { ghost: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", default: "bg-primary text-primary-foreground hover:bg-primary/90", }, }, defaultVariants: { size: "xs", variant: "ghost" }, }); type InputGroupButtonProps = Omit<React.ComponentProps<"button">, "size"> & VariantProps<typeof buttonVariants>; function InputGroupButton({ className, type = "button", variant = "ghost", size = "xs", ...props }: InputGroupButtonProps) { return ( <button type={type} data-slot="input-group-button" data-size={size} className={cn( buttonVariants({ variant, size }), "inline-flex shrink-0 items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none", className, )} {...props} /> ); } type InputGroupTextProps = React.ComponentProps<"span">; function InputGroupText({ className, ...props }: InputGroupTextProps) { return ( <span data-slot="input-group-text" className={cn( "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", className, )} {...props} /> ); } type InputGroupInputProps = React.ComponentProps<"input">; function InputGroupInput({ className, ...props }: InputGroupInputProps) { return ( <input data-slot="input-group-control" className={cn( "h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed", "group-has-[[data-align=inline-start]]/input-group:ps-1.5 group-has-[[data-align=inline-end]]/input-group:pe-1.5", className, )} {...props} /> ); } type InputGroupTextareaProps = React.ComponentProps<"textarea">; function InputGroupTextarea({ className, ...props }: InputGroupTextareaProps) { return ( <textarea data-slot="input-group-control" className={cn( "min-h-16 w-full min-w-0 flex-1 resize-none bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed", className, )} {...props} /> ); } export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea, type InputGroupProps, type InputGroupAddonProps, type InputGroupButtonProps, type InputGroupTextProps, type InputGroupInputProps, type InputGroupTextareaProps, };Update the import paths to match your project setup.
Usage
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea } from "@/components/ballmac/input-group"The full example is in the Code tab above.
Examples
Invalid and disabled
import { AlertCircle, Mail } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ballmac/input-group";
export default function InputGroupStates() {
return (
<div className="grid w-full max-w-sm gap-3">
<InputGroup aria-label="Invalid email">
<InputGroupAddon>
<Mail aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput aria-label="Email" aria-invalid="true" defaultValue="jordan@" />
<InputGroupAddon align="inline-end">
<AlertCircle aria-hidden="true" className="text-destructive" />
</InputGroupAddon>
</InputGroup>
<InputGroup aria-label="Disabled email">
<InputGroupAddon>
<Mail aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput aria-label="Billing email" disabled defaultValue="billing@acme.example" />
</InputGroup>
</div>
);
}API reference
<InputGroup>
| Prop | Type | Default |
|---|---|---|
"aria-label"Accessible name for the group, for example "Website address". | string | — |
<InputGroupAddon>
| Prop | Type | Default |
|---|---|---|
alignWhere the addon sits. Inline addons flank the control; block addons stack above or below it (for textareas). | "inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" |
align | "inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" |
<InputGroupButton>
| Prop | Type | Default |
|---|---|---|
size | "xs" | "sm" | "icon-xs" | "icon-sm" | "xs" |
variant | "ghost" | "secondary" | "default" | "ghost" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Click addon text | Focuses the control |
| Tab | Moves between the control and addon buttons |
Use with AI
Put InputGroupInput or InputGroupTextarea plus InputGroupAddon parts inside InputGroup. The group draws the border, focus ring and invalid state. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Input Group (@ballmac/input-group) to this project with the shadcn MCP, then use it where it fits.
Use it for
- URL and currency prefixes and suffixes
- Search fields with a button
- Chat composers with a send button
Not for
- A plain field; use input
- Attached separate buttons; use button-group
Registry JSON: https://ui.ballmac.com/r/input-group.json
Credits
Based on shadcn/ui Input Group, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.