Input Group
An input with prefix and suffix slots for icons, text, or action buttons. More flexible than leading/trailing icons — accepts any ReactNode.
AccessibleDark Mode3 Sizes3 VariantsPrefix/Suffix
Install
$
npx react-principles add input-group01
Live Demo
Explore all variants and interactive states in Storybook.
Open Storybookopen_in_new@
USD
$USD
https://.com
02
Code Snippet
src/ui/InputGroup.tsx
import { InputGroup } from "@/ui/InputGroup"; // Prefix only <InputGroup label="Search" placeholder="Search components..." prefix={<SearchIcon />} /> // Suffix only <InputGroup label="Amount" type="number" placeholder="0.00" suffix="USD" /> // Both prefix and suffix <InputGroup label="Price" type="number" placeholder="99.99" prefix="$" suffix="USD" />
03
Copy-Paste (Single File)
InputGroup.tsx
import { forwardRef, type ReactNode, type InputHTMLAttributes } from "react"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── export type InputGroupSize = "sm" | "md" | "lg"; export type InputGroupVariant = "default" | "filled" | "ghost"; export interface InputGroupProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> { label?: string; description?: string; error?: string; size?: InputGroupSize; variant?: InputGroupVariant; prefix?: ReactNode; suffix?: ReactNode; } // ─── Constants ──────────────────────────────────────────────────────────────── const SIZE_CLASSES: Record<InputGroupSize, { input: string; label: string; prefix: string; suffix: string; }> = { sm: { input: "h-8 px-3 text-xs", label: "text-xs", prefix: "pl-3 pr-2 text-xs", suffix: "pl-2 pr-3 text-xs", }, md: { input: "h-10 px-3.5 text-sm", label: "text-sm", prefix: "pl-3.5 pr-3 text-sm", suffix: "pl-3 pr-3.5 text-sm", }, lg: { input: "h-12 px-4 text-base", label: "text-sm", prefix: "pl-4 pr-3 text-base", suffix: "pl-3 pr-4 text-base", }, }; const VARIANT_BASE: Record<InputGroupVariant, string> = { default: "border border-slate-200 dark:border-[#1f2937] bg-white dark:bg-[#0d1117] " + "hover:border-slate-300 dark:hover:border-slate-600 " + "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20", filled: "border border-transparent bg-slate-100 dark:bg-[#161b22] " + "hover:bg-slate-150 dark:hover:bg-[#1f2937] " + "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 focus-within:bg-white dark:focus-within:bg-[#0d1117]", ghost: "border border-transparent bg-transparent " + "hover:bg-slate-50 dark:hover:bg-[#161b22] " + "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20", }; const ERROR_OVERRIDE = "border-red-400 dark:border-red-500 focus-within:border-red-400 dark:focus-within:border-red-500 focus-within:ring-red-400/20"; // ─── Component ──────────────────────────────────────────────────────────────── export const InputGroup = forwardRef<HTMLInputElement, InputGroupProps>( function InputGroupRoot( { label, description, error, size = "md", variant = "default", prefix, suffix, disabled, className, id, ...rest }, ref ) { const s = SIZE_CLASSES[size]; const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); const inputPadding = cn( s.input, "bg-transparent outline-hidden border-0 p-0 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500", prefix && "pl-0", suffix && "pr-0" ); return ( <div className={cn("flex flex-col gap-1.5", className)}> {label && ( <label htmlFor={inputId} className={cn("font-medium text-slate-700 dark:text-slate-300", s.label, disabled && "opacity-50")} > {label} </label> )} <div className={cn( "relative flex items-center rounded-lg transition-all", VARIANT_BASE[variant], error && ERROR_OVERRIDE, disabled && "opacity-50 cursor-not-allowed pointer-events-none" )} > {prefix && ( <span className={cn("flex shrink-0 items-center text-slate-500 dark:text-slate-400", s.prefix)}> {prefix} </span> )} <input ref={ref} id={inputId} disabled={disabled} className={inputPadding} {...rest} /> {suffix && ( <span className={cn("flex shrink-0 items-center text-slate-500 dark:text-slate-400", s.suffix)}> {suffix} </span> )} </div> {description && !error && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>} {error && <p className="text-xs text-red-500 dark:text-red-400">{error}</p>} </div> ); } );
04
Props
Extends all native HTMLInputElement attributes (except size and prefix).
| Prop | Type | Default | Description |
|---|---|---|---|
prefix | ReactNode | — | Content rendered on the left side of the input. |
suffix | ReactNode | — | Content rendered on the right side of the input. |
size | "sm" | "md" | "lg" | "md" | Controls input height and text size. |
variant | "default" | "filled" | "ghost" | "default" | Visual style of the input wrapper. |
error | string | — | Error message — turns border red and replaces description. |
disabled | boolean | false | Disables interaction and reduces opacity. |