Addon Group Input
Original · freeinput group with leading/trailing addons
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/inp-addon-group.json"use client";
import { useId, useState } from "react";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type ClassParts = Array<string | false | null | undefined>;
const cx = (...parts: ClassParts): string => parts.filter(Boolean).join(" ");
const RESERVED = new Set([
"admin",
"root",
"support",
"help",
"team",
"billing",
"api",
"www",
"innoventix",
]);
const CURRENCY: Record<string, { symbol: string; code: "USD" | "EUR" | "GBP" }> = {
USD: { symbol: "$", code: "USD" },
EUR: { symbol: "€", code: "EUR" },
GBP: { symbol: "£", code: "GBP" },
};
const API_KEY = "pk_live_51Qa9Zt2eRk7vG3mNbXcW8yHf";
const INPUT =
"min-w-0 flex-1 bg-transparent px-3 py-2.5 text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500";
const FOCUS_RING =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400";
/* ---------------------------------- icons --------------------------------- */
function Chevron({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GlobeIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.8" />
<path
d="M3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3Z"
stroke="currentColor"
strokeWidth="1.8"
/>
</svg>
);
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
<path d="m20 20-3.2-3.2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function KeyIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<circle cx="8" cy="8" r="4.5" stroke="currentColor" strokeWidth="1.8" />
<path
d="m11 11 8 8m-3 0 2-2m-4-2 2-2"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CopyIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<path
d="M5 15V6a2 2 0 0 1 2-2h9"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function EyeIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path
d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"
stroke="currentColor"
strokeWidth="1.8"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.8" />
</svg>
);
}
function EyeOffIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path
d="M4 4l16 16M9.9 5.2A9.6 9.6 0 0 1 12 5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-3 3.6M6.4 7.4A15.7 15.7 0 0 0 2.5 11.5S6 18 12 18a9 9 0 0 0 3.2-.6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function BoxIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path
d="M12 3 4 7v10l8 4 8-4V7l-8-4Zm0 0 8 4-8 4-8-4m8 4v10"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
/>
</svg>
);
}
/* -------------------------------- primitives ------------------------------ */
function Group({
tone = "default",
children,
}: {
tone?: "default" | "invalid" | "valid";
children: ReactNode;
}) {
return (
<div
className={cx(
"flex items-stretch overflow-hidden rounded-xl border bg-white text-sm shadow-sm transition-[box-shadow,border-color] dark:bg-slate-900/70",
tone === "default" &&
"border-slate-300 focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/40 dark:border-slate-700 dark:focus-within:border-indigo-400",
tone === "invalid" &&
"border-rose-400 focus-within:border-rose-500 focus-within:ring-2 focus-within:ring-rose-500/40 dark:border-rose-500/70",
tone === "valid" &&
"border-emerald-400 focus-within:border-emerald-500 focus-within:ring-2 focus-within:ring-emerald-500/40 dark:border-emerald-500/70"
)}
>
{children}
</div>
);
}
function Addon({
side,
strong = false,
children,
}: {
side: "leading" | "trailing";
strong?: boolean;
children: ReactNode;
}) {
return (
<span
className={cx(
"flex select-none items-center gap-1.5 px-3 text-sm font-medium",
side === "leading" ? "border-r" : "border-l",
"border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800/70",
strong ? "text-slate-700 dark:text-slate-200" : "text-slate-500 dark:text-slate-400"
)}
>
{children}
</span>
);
}
function SelectAddon({
side,
value,
onChange,
ariaLabel,
children,
}: {
side: "leading" | "trailing";
value: string;
onChange: (v: string) => void;
ariaLabel: string;
children: ReactNode;
}) {
return (
<span
className={cx(
"relative flex items-center",
side === "leading" ? "border-r" : "border-l",
"border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800/70"
)}
>
<select
aria-label={ariaLabel}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cx(
"h-full cursor-pointer appearance-none bg-transparent py-2.5 pl-3 pr-8 text-sm font-medium text-slate-700 dark:text-slate-200",
FOCUS_RING
)}
>
{children}
</select>
<Chevron className="pointer-events-none absolute right-2.5 h-4 w-4 text-slate-400" />
</span>
);
}
function Field({
label,
htmlFor,
children,
hint,
}: {
label: string;
htmlFor: string;
children: ReactNode;
hint?: ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<label
htmlFor={htmlFor}
className="text-sm font-semibold text-slate-800 dark:text-slate-100"
>
{label}
</label>
{children}
{hint ? <div className="min-h-[1rem] text-xs text-slate-500 dark:text-slate-400">{hint}</div> : null}
</div>
);
}
function Card({
index,
reduce,
children,
}: {
index: number;
reduce: boolean;
children: ReactNode;
}) {
return (
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.45, delay: index * 0.06, ease: [0.16, 1, 0.3, 1] }}
className="rounded-2xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/40"
>
{children}
</motion.div>
);
}
/* ------------------------------- main export ------------------------------ */
export default function InputAddonGroup() {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const id = (name: string) => `${uid}-${name}`;
const [siteName, setSiteName] = useState("aisha-patel");
const [tld, setTld] = useState(".dev");
const [amount, setAmount] = useState("1200");
const [currency, setCurrency] = useState("USD");
const [handle, setHandle] = useState("aisha");
const [scope, setScope] = useState("all");
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
const [weight, setWeight] = useState("2.4");
const [unit, setUnit] = useState<"kg" | "lb">("kg");
const cleanHandle = handle.trim().toLowerCase();
const handleState =
handle.length === 0
? "idle"
: cleanHandle.length < 3
? "short"
: RESERVED.has(cleanHandle)
? "taken"
: "available";
const amountNum = Number(amount);
const amountPreview =
amount.trim() !== "" && Number.isFinite(amountNum)
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: CURRENCY[currency].code,
maximumFractionDigits: 2,
}).format(amountNum)
: null;
const scopeLabel: Record<string, string> = {
all: "everything",
guides: "Guides",
api: "API reference",
cli: "the CLI",
};
async function copyKey() {
try {
await navigator.clipboard.writeText(API_KEY);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}
const maskedKey = `${API_KEY.slice(0, 8)}${"•".repeat(18)}`;
const keyframes = `
@keyframes iag-pop {
0% { transform: scale(0.7); opacity: 0; }
60% { transform: scale(1.08); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes iag-slide {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.iag-pop { animation: iag-pop 0.28s cubic-bezier(0.16, 1, 0.3, 1) both; }
.iag-slide { animation: iag-slide 0.3s ease both; }
@media (prefers-reduced-motion: reduce) {
.iag-pop, .iag-slide { animation: none !important; }
}
`;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
<style>{keyframes}</style>
<div className="mx-auto max-w-5xl">
<header className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:border-slate-700 dark:bg-slate-900 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Form primitives
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Inputs with leading & trailing addons
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Fixed prefixes, live currency symbols, inline selects and action buttons that share a
single focus ring with the field. Every control is keyboard-operable and screen-reader
labelled.
</p>
</header>
<div className="mt-10 grid gap-5 sm:gap-6 md:grid-cols-2">
{/* 1 — Website: leading static + trailing select */}
<Card index={0} reduce={reduce}>
<Field
label="Personal website"
htmlFor={id("site")}
hint={
<>
Live URL:{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">
https://{siteName || "yourname"}
{tld}
</span>
</>
}
>
<Group>
<Addon side="leading">
<GlobeIcon className="h-4 w-4" />
https://
</Addon>
<input
id={id("site")}
type="text"
inputMode="url"
autoCapitalize="none"
spellCheck={false}
value={siteName}
onChange={(e) => setSiteName(e.target.value.replace(/\s+/g, ""))}
placeholder="yourname"
className={INPUT}
/>
<SelectAddon
side="trailing"
value={tld}
onChange={setTld}
ariaLabel="Top-level domain"
>
<option value=".dev">.dev</option>
<option value=".io">.io</option>
<option value=".com">.com</option>
<option value=".me">.me</option>
</SelectAddon>
</Group>
</Field>
</Card>
{/* 2 — Budget: leading currency symbol + trailing currency select */}
<Card index={1} reduce={reduce}>
<Field
label="Monthly budget"
htmlFor={id("budget")}
hint={
amountPreview ? (
<>
Billed as{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">
{amountPreview}
</span>{" "}
/ month
</>
) : (
"Enter a whole or decimal amount."
)
}
>
<Group>
<Addon side="leading" strong>
{CURRENCY[currency].symbol}
</Addon>
<input
id={id("budget")}
type="text"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value.replace(/[^0-9.]/g, ""))}
placeholder="0.00"
className={INPUT}
/>
<SelectAddon
side="trailing"
value={currency}
onChange={setCurrency}
ariaLabel="Currency"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</SelectAddon>
</Group>
</Field>
</Card>
{/* 3 — Handle: leading @ + trailing live availability */}
<Card index={2} reduce={reduce}>
<Field
label="Team handle"
htmlFor={id("handle")}
hint={
<span
aria-live="polite"
className={cx(
handleState === "taken" && "text-rose-600 dark:text-rose-400",
handleState === "available" && "text-emerald-600 dark:text-emerald-400"
)}
>
{handleState === "idle" && "Lowercase letters, numbers and dashes."}
{handleState === "short" && "Handles need at least 3 characters."}
{handleState === "taken" && `@${cleanHandle} is already reserved.`}
{handleState === "available" && `@${cleanHandle} is available.`}
</span>
}
>
<Group
tone={
handleState === "taken"
? "invalid"
: handleState === "available"
? "valid"
: "default"
}
>
<Addon side="leading" strong>
@
</Addon>
<input
id={id("handle")}
type="text"
autoCapitalize="none"
spellCheck={false}
aria-invalid={handleState === "taken"}
value={handle}
onChange={(e) => setHandle(e.target.value.replace(/[^a-zA-Z0-9-]/g, ""))}
placeholder="your-handle"
className={INPUT}
/>
{handleState === "taken" || handleState === "available" ? (
<span
className={cx(
"iag-pop flex items-center gap-1.5 border-l px-3 text-xs font-semibold",
handleState === "available"
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
)}
>
{handleState === "available" ? (
<CheckIcon className="h-3.5 w-3.5" />
) : null}
{handleState === "available" ? "Available" : "Taken"}
</span>
) : null}
</Group>
</Field>
</Card>
{/* 4 — Search: leading scope select + trailing submit button */}
<Card index={3} reduce={reduce}>
<Field
label="Search the docs"
htmlFor={id("search")}
hint={
<span aria-live="polite">
{submitted ? (
<span className="iag-slide inline-flex items-center gap-1.5 text-slate-700 dark:text-slate-200">
Searched{" "}
<span className="font-medium text-indigo-600 dark:text-indigo-400">
“{submitted}”
</span>{" "}
in {scopeLabel[scope]}
</span>
) : (
"Pick a scope, then press Enter or Search."
)}
</span>
}
>
<form
onSubmit={(e) => {
e.preventDefault();
if (query.trim()) setSubmitted(query.trim());
}}
>
<Group>
<SelectAddon
side="leading"
value={scope}
onChange={setScope}
ariaLabel="Search scope"
>
<option value="all">All</option>
<option value="guides">Guides</option>
<option value="api">API</option>
<option value="cli">CLI</option>
</SelectAddon>
<input
id={id("search")}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. rate limits"
className={INPUT}
/>
<button
type="submit"
className={cx(
"flex items-center gap-1.5 bg-indigo-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 active:bg-indigo-700",
FOCUS_RING,
"dark:bg-indigo-500 dark:hover:bg-indigo-400"
)}
>
<SearchIcon className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
</button>
</Group>
</form>
</Field>
</Card>
{/* 5 — API key: leading icon + trailing reveal/copy buttons */}
<Card index={4} reduce={reduce}>
<Field
label="Publishable API key"
htmlFor={id("key")}
hint="Safe to embed in client-side code."
>
<Group>
<Addon side="leading">
<KeyIcon className="h-4 w-4" />
</Addon>
<input
id={id("key")}
type="text"
readOnly
value={revealed ? API_KEY : maskedKey}
aria-label="Publishable API key"
className={cx(INPUT, "font-mono text-[13px] tracking-tight")}
/>
<span className="flex items-center border-l border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800/70">
<button
type="button"
onClick={() => setRevealed((v) => !v)}
aria-pressed={revealed}
aria-label={revealed ? "Hide API key" : "Show API key"}
className={cx(
"flex h-full items-center px-2.5 text-slate-500 transition-colors hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100",
FOCUS_RING
)}
>
{revealed ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={copyKey}
aria-label="Copy API key to clipboard"
className={cx(
"flex h-full items-center gap-1.5 border-l border-slate-200 px-3 text-sm font-semibold transition-colors dark:border-slate-800",
copied
? "text-emerald-600 dark:text-emerald-400"
: "text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300",
FOCUS_RING
)}
>
{copied ? (
<>
<CheckIcon className="h-4 w-4" />
<span className="iag-pop">Copied</span>
</>
) : (
<>
<CopyIcon className="h-4 w-4" />
<span>Copy</span>
</>
)}
</button>
</span>
</Group>
<span role="status" aria-live="polite" className="sr-only">
{copied ? "API key copied to clipboard" : ""}
</span>
</Field>
</Card>
{/* 6 — Weight: leading icon + trailing segmented unit toggle */}
<Card index={5} reduce={reduce}>
<Field
label="Package weight"
htmlFor={id("weight")}
hint="Used to estimate shipping at checkout."
>
<Group>
<Addon side="leading">
<BoxIcon className="h-4 w-4" />
</Addon>
<input
id={id("weight")}
type="text"
inputMode="decimal"
value={weight}
onChange={(e) => setWeight(e.target.value.replace(/[^0-9.]/g, ""))}
placeholder="0.0"
className={INPUT}
/>
<span className="flex items-center gap-1 border-l border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-800/70">
{(["kg", "lb"] as const).map((u) => (
<button
key={u}
type="button"
aria-pressed={unit === u}
onClick={() => setUnit(u)}
className={cx(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
FOCUS_RING,
unit === u
? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
)}
>
{u}
</button>
))}
</span>
</Group>
</Field>
</Card>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Set Input
Originaldefault, hover, focus, disabled, error text inputs

Floating Label Input
Originallabel that floats up on focus/fill

Filled Input
Originalfilled material-style inputs

Underline Input
Originalminimal underline inputs with animated bar

Search Input
Originalsearch box with icon and clear

Search Suggest Input
Originalsearch with a live suggestions dropdown

Password Toggle Input
Originalpassword field with show/hide toggle

Textarea Autosize Input
Originaltextarea that grows with content

Currency Input
Originalcurrency input with formatting

Tags Input
Originaltag input, add and remove chips

OTP Input
Original6-box one-time-code input with paste

Phone Input
Originalphone input with country prefix select

