Web InnoventixFreeCode

Addon Group Input

Original · free

input group with leading/trailing addons

byWeb InnoventixReact + Tailwind
inpaddongroupinputs
Open

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
inp-addon-group.tsx
"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 &amp; 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">
                        &ldquo;{submitted}&rdquo;
                      </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

motion

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 quote

Similar components

Browse all →