# Input Group

> Input or Textarea with icon, text, button and toolbar addons inside one field box that glows as a whole.

Category: Forms. Docs: https://neonkit.heyaadi.com/components/input-group

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/input-group.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/input-group`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Button](https://neonkit.heyaadi.com/components/button.md), [Input](https://neonkit.heyaadi.com/components/input.md), [Textarea](https://neonkit.heyaadi.com/components/textarea.md). The CLI installs them too.

## Usage

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

<InputGroup>
  <InputGroupAddon>
    <InputGroupText>https://</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput aria-label="Domain" placeholder="acme.dev" />
</InputGroup>
```

## Examples

### Prefixes, units and a copy button

Text addons sit flush with the value so it reads as one URL. The glow wraps the whole group.

```tsx
"use client";

import * as React from "react";
import { Check, Copy } from "@/components/ui/icons";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";

export default function InputGroupDemo() {
  const [slug, setSlug] = React.useState("spring-pottery-class");
  const [amount, setAmount] = React.useState("85.00");
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(timer);
  }, [copied]);

  return (
    <form className="grid min-w-0 w-full max-w-md gap-5" onSubmit={(event) => event.preventDefault()}>
      <div className="grid gap-1.5">
        <label htmlFor="pay-amount" className="text-sm font-medium text-white/80">
          Price
        </label>
        <InputGroup>
          <InputGroupAddon>
            <InputGroupText className="text-white/70">€</InputGroupText>
          </InputGroupAddon>
          <InputGroupInput
            id="pay-amount"
            inputMode="decimal"
            value={amount}
            onChange={(event) => setAmount(event.target.value)}
            className="tabular-nums"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupText>EUR</InputGroupText>
          </InputGroupAddon>
        </InputGroup>
      </div>

      <div className="grid gap-1.5">
        <label htmlFor="pay-link" className="text-sm font-medium text-white/80">
          Payment link
        </label>
        <InputGroup>
          <InputGroupAddon>
            <InputGroupText>pay.acme.co/</InputGroupText>
          </InputGroupAddon>
          <InputGroupInput
            id="pay-link"
            value={slug}
            spellCheck={false}
            onChange={(event) => setSlug(event.target.value.replace(/\s+/g, "-").toLowerCase())}
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              size="icon"
              aria-label={copied ? "Copied" : "Copy link"}
              onClick={() => {
                void navigator.clipboard?.writeText(`https://pay.acme.co/${slug}`).catch(() => {});
                setCopied(true);
              }}
              className={copied ? "text-[rgb(var(--neon))]" : undefined}
            >
              {copied ? <Check /> : <Copy />}
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
        <p className="text-xs text-white/50" aria-live="polite">
          {copied ? "Link copied." : "Anyone with the link can pay. You can turn it off later."}
        </p>
      </div>
    </form>
  );
}
```

### Search

The trailing addon swaps between a ⌘K hint, a spinner and a result count. ⌘K focuses the field.

```tsx
"use client";

import * as React from "react";
import { Search } from "@/components/ui/icons";
import { Kbd } from "@/components/ui/kbd";
import { Spinner } from "@/components/ui/spinner";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";

const ISSUES = 1284;

export default function InputGroupSearch() {
  const [query, setQuery] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        inputRef.current?.focus();
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  React.useEffect(() => {
    if (!query) return;
    const timer = window.setTimeout(() => setBusy(false), 450);
    return () => window.clearTimeout(timer);
  }, [query]);

  const results = query ? Math.max(0, 37 - query.length * 6) : ISSUES;

  return (
    <div className="min-w-0 w-full max-w-md">
      <InputGroup>
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
        <InputGroupInput
          ref={inputRef}
          type="search"
          aria-label="Search issues"
          placeholder="Search issues"
          value={query}
          onChange={(event) => {
            setQuery(event.target.value);
            setBusy(Boolean(event.target.value));
          }}
        />
        <InputGroupAddon align="inline-end">
          {busy ? (
            <Spinner size="sm" label="Searching" />
          ) : query ? (
            <InputGroupText className="tabular-nums">{results} results</InputGroupText>
          ) : (
            <Kbd size="sm" listen="k">⌘K</Kbd>
          )}
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}
```

### Message box

A growing Textarea with a toolbar row below. Send only lights up once there's something to send.

```tsx
"use client";

import * as React from "react";
import { ArrowUp, AtSign, Paperclip } from "@/components/ui/icons";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";

export default function InputGroupMessage() {
  const [text, setText] = React.useState("");
  const [sent, setSent] = React.useState<string[]>([]);
  const ready = text.trim().length > 0;

  function send() {
    if (!ready) return;
    setSent((list) => [...list, text.trim()]);
    setText("");
  }

  return (
    <div className="min-w-0 w-full max-w-lg">
      {sent.length > 0 && (
        <ul className="mb-3 grid gap-1.5 text-sm text-white/80">
          {sent.map((message, index) => (
            <li key={index} className="ml-auto w-fit max-w-[80%] rounded-xl rounded-br-md bg-white/[0.06] px-3 py-2">
              {message}
            </li>
          ))}
        </ul>
      )}
      <InputGroup>
        <InputGroupTextarea
          aria-label="Reply in #design-crit"
          placeholder="Reply in #design-crit"
          value={text}
          maxRows={6}
          onChange={(event) => setText(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
              event.preventDefault();
              send();
            }
          }}
        />
        <InputGroupAddon align="block-end">
          <InputGroupButton size="icon" aria-label="Attach a file">
            <Paperclip />
          </InputGroupButton>
          <InputGroupButton size="icon" aria-label="Mention someone">
            <AtSign />
          </InputGroupButton>
          <InputGroupText className="ml-auto hidden text-xs sm:flex">⌘↵ to send</InputGroupText>
          <InputGroupButton
            size="icon"
            variant={ready ? "solid" : "ghost"}
            aria-label="Send"
            disabled={!ready}
            onClick={send}
            className={
              ready
                ? "ml-auto rounded-full sm:ml-0"
                : "ml-auto rounded-full border border-white/10 text-white/35 disabled:opacity-100 sm:ml-0"
            }
          >
            <ArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  );
}
```

### Availability

aria-invalid on the control turns the group and its focus glow red.

```tsx
"use client";

import * as React from "react";
import { Check } from "@/components/ui/icons";
import { Spinner } from "@/components/ui/spinner";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";

const TAKEN = ["ana", "acme", "admin", "design", "studio"];

export default function InputGroupUsername() {
  const [name, setName] = React.useState("ana");
  const [checking, setChecking] = React.useState(false);

  React.useEffect(() => {
    if (!checking) return;
    const timer = window.setTimeout(() => setChecking(false), 600);
    return () => window.clearTimeout(timer);
  }, [checking, name]);

  const taken = TAKEN.includes(name.toLowerCase());
  const status = !name ? null : checking ? "checking" : taken ? "taken" : "free";

  return (
    <div className="grid min-w-0 w-full max-w-sm gap-1.5">
      <label htmlFor="handle" className="text-sm font-medium text-white/80">
        Profile URL
      </label>
      <InputGroup>
        <InputGroupAddon>
          <InputGroupText>acme.dev/</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput
          id="handle"
          value={name}
          spellCheck={false}
          autoComplete="off"
          aria-invalid={status === "taken" || undefined}
          aria-describedby="handle-status"
          onChange={(event) => {
            setName(event.target.value.replace(/[^a-z0-9-]/gi, ""));
            setChecking(true);
          }}
        />
        <InputGroupAddon align="inline-end">
          {status === "checking" && <Spinner size="sm" label="Checking" />}
          {status === "free" && <Check className="text-[rgb(52_211_153)]" aria-hidden />}
        </InputGroupAddon>
      </InputGroup>
      <p id="handle-status" aria-live="polite" className={status === "taken" ? "text-xs text-[#ff4d5e]" : "text-xs text-white/50"}>
        {status === "taken" ? `acme.dev/${name} is taken. Try ${name}-studio.` : status === "free" ? "Available." : "Letters, numbers and dashes."}
      </p>
    </div>
  );
}
```

## API reference

### InputGroup

The field box, a div with role="group". A click on anything but a button focuses the control.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 32, 40 or 48px tall. Passed to the control. |
| `color` | `NeonColor` | `inherited` | Color of the focus glow. |

### InputGroupAddon

Holds icons, text, buttons, a Kbd or a Spinner. Icons light up while the group has focus.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"inline-start" \| "inline-end" \| "block-start" \| "block-end"` | `"inline-start"` | Before or after the control on its line, or a full-width row above or below it. |

### InputGroupInput

NeonKit's Input without its own box. Takes every Input prop except label, description, error, leading, trailing and size.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-invalid` | `boolean` | — | Turns the whole group red. |

### InputGroupTextarea

NeonKit's Textarea without its own box. Grows with its content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `minRows` | `number` | `2` | Rows before it starts growing. |
| `maxRows` | `number` | `10` | Rows before it scrolls. |

### InputGroupButton

A Button sized to sit inside the field. Takes every Button prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "icon"` | `"sm"` | 28px tall text button, or a 28px square. |
| `variant` | `ButtonVariant` | `"ghost"` | Any Button variant. |

### InputGroupText

Muted inline text, for prefixes, suffixes and units. Sits flush against the value.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Merged onto the span. |

## Accessibility

- Label the control, not the group: a <label htmlFor> or aria-label on InputGroupInput.
- Addons are outside the input, so screen readers don't read a prefix as part of the value; mention it in the label when it matters.
- Use aria-invalid with aria-describedby pointing at the message, as in the availability example.

## Source

`components/ui/input-group.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";
import { Button, type ButtonProps } from "@/components/ui/button";
import { Input, type InputProps } from "@/components/ui/input";
import { Textarea, type TextareaProps } from "@/components/ui/textarea";

type Size = "sm" | "md" | "lg";

const CONTROL = "[data-nk-input-group-control]";

const SizeContext = React.createContext<Size>("md");

const sizes = {
  sm: { box: "min-h-8 rounded-md text-xs", control: "[&>input]:px-2.5", inline: "px-2.5 [&_svg]:size-3.5" },
  md: { box: "min-h-10 rounded-lg text-sm", control: "[&>input]:px-3", inline: "px-3 [&_svg]:size-4" },
  lg: { box: "min-h-12 rounded-xl text-base", control: "[&>input]:px-4", inline: "px-4 [&_svg]:size-[18px]" },
} as const;

export interface InputGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: Size;
}

/**
 * One field box around a control and its addons. The focus glow wraps the whole group, and
 * a click anywhere that isn't a button lands in the control.
 */
const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
  ({ className, style, color, size = "md", onMouseDown, ...props }, ref) => (
    <SizeContext.Provider value={size}>
      <div
        ref={ref}
        role="group"
        data-size={size}
        style={neonVars(color, style)}
        onMouseDown={(event) => {
          onMouseDown?.(event);
          if (event.defaultPrevented) return;
          const target = event.target as HTMLElement;
          if (target.closest("button, a, input, textarea, select, [tabindex], [contenteditable]")) return;
          const control = event.currentTarget.querySelector<HTMLElement>(CONTROL);
          if (!control || control.matches(":disabled")) return;
          event.preventDefault();
          control.focus();
        }}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "group/input-group relative flex w-full min-w-0 cursor-text flex-wrap items-center border border-white/10 bg-white/[0.03] text-white/90",
          "transition-[border-color,background-color,box-shadow] ease-[cubic-bezier(0.23,1,0.32,1)] duration-[150ms,150ms,320ms]",
          "hover:[&:not(:focus-within)]:border-white/20",
          "focus-within:border-[rgb(var(--neon)/0.8)] focus-within:bg-[rgb(var(--neon)/0.04)] focus-within:shadow-[0_0_0_1px_rgb(var(--neon)/0.2),0_0_20px_-4px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),inset_0_0_12px_-2px_rgb(var(--neon)/CALC(0.14*var(--neon-glow,1)))]",
          // Invalid: the whole group turns red, and so does its glow when focused.
          "has-[[aria-invalid=true]]:border-[rgb(var(--neon)/0.55)] has-[[aria-invalid=true]]:[--neon:255_77_94]",
          "has-[[data-nk-input-group-control]:disabled]:cursor-not-allowed has-[[data-nk-input-group-control]:disabled]:opacity-50",
          // A textarea grows the box; block addons become rows above or below it.
          "has-[textarea]:items-stretch",
          sizes[size].box,
          className,
        )}
        {...props}
      />
    </SizeContext.Provider>
  ),
);
InputGroup.displayName = "InputGroup";

const align = {
  "inline-start": "order-first self-stretch",
  "inline-end": "order-last self-stretch",
  "block-start": "order-first w-full justify-start",
  "block-end": "order-last w-full justify-start",
} as const;

export interface InputGroupAddonProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Before or after the control on its line, or a full-width row above or below it. */
  align?: keyof typeof align;
}

const InputGroupAddon = React.forwardRef<HTMLDivElement, InputGroupAddonProps>(
  ({ className, align: alignProp = "inline-start", ...props }, ref) => {
    const size = React.useContext(SizeContext);
    const inline = alignProp.startsWith("inline");
    return (
      <div
        ref={ref}
        data-align={alignProp}
        className={cn(
          "flex shrink-0 select-none items-center gap-2 text-white/50",
          "[&_svg]:shrink-0 [&_svg]:transition-[color,filter] [&_svg]:duration-200",
          "group-focus-within/input-group:[&>svg]:text-[rgb(var(--neon)/0.9)]",
          align[alignProp],
          inline ? sizes[size].inline : "px-3 py-2",
          // Buttons sit closer to the edge than icons, so their own padding does the spacing.
          alignProp === "inline-start" && "pr-0 has-[>button:first-child]:pl-1",
          alignProp === "inline-end" && "pl-0 has-[>button:last-child]:pr-1",
          alignProp === "block-start" && "border-b border-white/[0.06] group-has-[textarea]/input-group:border-b-0 group-has-[textarea]/input-group:pb-0",
          alignProp === "block-end" && "pt-0 group-has-[textarea]/input-group:pb-2",
          className,
        )}
        {...props}
      />
    );
  },
);
InputGroupAddon.displayName = "InputGroupAddon";

const InputGroupText = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
  ({ className, ...props }, ref) => (
    <span
      ref={ref}
      data-slot="input-group-text"
      className={cn("flex items-center gap-1.5 whitespace-nowrap text-white/50 [&_svg]:pointer-events-none", className)}
      {...props}
    />
  ),
);
InputGroupText.displayName = "InputGroupText";

// Strips the field box off Input and Textarea; the group draws it instead.
const unboxed = cn(
  "h-auto min-h-0 flex-1 self-stretch rounded-none border-0 bg-transparent shadow-none",
  "hover:[&:not(:focus-within)]:border-transparent focus-within:border-transparent focus-within:bg-transparent focus-within:shadow-none",
);

export type InputGroupInputProps = Omit<InputProps, "label" | "description" | "error" | "leading" | "trailing" | "size">;

/** NeonKit's Input without its own box. Pass aria-invalid to turn the group red. */
const InputGroupInput = React.forwardRef<HTMLInputElement, InputGroupInputProps>(
  ({ className, containerClassName, ...props }, ref) => {
    const size = React.useContext(SizeContext);
    return (
      <Input
        ref={ref}
        size={size}
        data-nk-input-group-control=""
        containerClassName={cn("w-auto min-w-0 flex-1 self-stretch [&>div]:h-full", containerClassName)}
        className={cn(
          unboxed,
          sizes[size].control,
          // Text starts just after a leading icon or prefix, and stops just before a trailing one.
          "group-has-[>[data-align=inline-start]]/input-group:[&>input]:pl-2",
          "group-has-[>[data-align=inline-end]]/input-group:[&>input]:pr-2",
          // A text prefix or suffix ("https://", "€") reads as part of the value, so it sits flush.
          "group-has-[>[data-align=inline-start]>[data-slot=input-group-text]:last-child]/input-group:[&>input]:pl-0.5",
          "group-has-[>[data-align=inline-end]>[data-slot=input-group-text]:first-child]/input-group:[&>input]:pr-1",
          className,
        )}
        {...(props as InputProps)}
      />
    );
  },
);
InputGroupInput.displayName = "InputGroupInput";

export type InputGroupTextareaProps = Omit<TextareaProps, "label" | "description" | "error" | "size">;

/** NeonKit's Textarea without its own box. Grows with its content by default. */
const InputGroupTextarea = React.forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
  ({ className, containerClassName, minRows = 2, ...props }, ref) => {
    const size = React.useContext(SizeContext);
    return (
      <Textarea
        ref={ref}
        size={size}
        minRows={minRows}
        data-nk-input-group-control=""
        containerClassName={cn("w-full", containerClassName)}
        className={cn(unboxed, className)}
        {...props}
      />
    );
  },
);
InputGroupTextarea.displayName = "InputGroupTextarea";

const buttonSizes = {
  sm: "h-7 gap-1.5 rounded-md px-2.5 text-xs",
  icon: "size-7 rounded-md px-0 [&_svg]:size-4",
} as const;

export interface InputGroupButtonProps extends Omit<ButtonProps, "size"> {
  /** `sm` for a text button, `icon` for a square one; both fit inside a 40px field. */
  size?: keyof typeof buttonSizes;
}

const InputGroupButton = React.forwardRef<HTMLButtonElement, InputGroupButtonProps>(
  ({ className, size = "sm", variant = "ghost", type = "button", ...props }, ref) => (
    <Button
      ref={ref}
      type={type}
      variant={variant}
      size="sm"
      className={cn(
        buttonSizes[size],
        "focus-visible:ring-offset-0",
        variant === "ghost" && "text-white/55",
        className,
      )}
      {...props}
    />
  ),
);
InputGroupButton.displayName = "InputGroupButton";

export {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupInput,
  InputGroupTextarea,
  InputGroupButton,
};
```
