# Input

> Text field with an animated neon focus ring, icons, labels and error states.

Category: Forms. Docs: https://get-neonkit.vercel.app/components/input

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/input.json
```

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

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

## Usage

```tsx
import { Input } from "@/components/ui/input"

<Input label="Email" type="email" placeholder="you@company.com" />
```

## Examples

### Sign in

```tsx
"use client";

import * as React from "react";
import { Eye, EyeOff, LockKeyhole, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export default function InputDemo() {
  const [visible, setVisible] = React.useState(false);

  return (
    <form className="grid w-full max-w-sm gap-4" onSubmit={(event) => event.preventDefault()}>
      <Input
        type="email"
        label="Email"
        placeholder="you@company.com"
        autoComplete="email"
        leading={<Mail />}
      />
      <Input
        type={visible ? "text" : "password"}
        label="Password"
        placeholder="Enter your password"
        autoComplete="current-password"
        leading={<LockKeyhole />}
        trailing={
          <button
            type="button"
            aria-label="Show password"
            aria-pressed={visible}
            onClick={() => setVisible((v) => !v)}
            className="-mr-1.5 grid size-7 cursor-pointer place-items-center rounded-md text-white/40 outline-none transition-colors hover:text-white/80 focus-visible:bg-[rgb(var(--neon)/0.12)] focus-visible:text-[rgb(var(--neon))]"
          >
            {visible ? <EyeOff /> : <Eye />}
          </button>
        }
      />
      <Button type="submit" variant="solid" className="mt-1 w-full">
        Sign in
      </Button>
    </form>
  );
}
```

### Search with a shortcut

trailing holds hints and small buttons. Press / to focus this one.

```tsx
"use client";

import * as React from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";

export default function InputSearch() {
  const ref = React.useRef<HTMLInputElement>(null);
  const [focused, setFocused] = React.useState(false);

  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.defaultPrevented || event.key !== "/") return;
      const target = event.target as HTMLElement;
      // Let people type a slash into other fields.
      if (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
      event.preventDefault();
      ref.current?.focus();
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  return (
    <Input
      ref={ref}
      type="search"
      aria-label="Search documentation"
      placeholder="Search components and props"
      leading={<Search />}
      trailing={
        <kbd className="pointer-events-none rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-sans text-[11px] font-medium leading-4 text-white/45">
          {focused ? "Esc" : "/"}
        </kbd>
      }
      onFocus={() => setFocused(true)}
      onBlur={() => setFocused(false)}
      onKeyDown={(event) => {
        if (event.key === "Escape") event.currentTarget.blur();
      }}
      containerClassName="max-w-sm"
    />
  );
}
```

### Validation

error turns the field red, sets aria-invalid and takes the description's place. leading also takes text addons.

```tsx
"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { Input } from "@/components/ui/input";

const taken = ["aadi", "admin", "neon", "support"];

function validate(handle: string) {
  if (!handle) return "Pick a handle for your profile link.";
  if (!/^[a-z0-9-]+$/.test(handle)) return "Use lowercase letters, numbers and dashes.";
  if (handle.length < 3) return "Handles are at least 3 characters.";
  if (taken.includes(handle)) return `That one is taken. Try ${handle}-dev.`;
  return undefined;
}

export default function InputValidation() {
  const [handle, setHandle] = React.useState("aadi");
  const error = validate(handle);

  return (
    <Input
      label="Profile URL"
      description="Your public page. You can change it once every 30 days."
      value={handle}
      onChange={(event) => setHandle(event.target.value.trim())}
      error={error}
      spellCheck={false}
      autoCapitalize="none"
      leading={<span className="-mr-1.5 select-none">neonkit.dev/</span>}
      trailing={error ? null : <Check className="text-[#34d399]" aria-hidden />}
      containerClassName="max-w-sm"
    />
  );
}
```

### Read-only and disabled

Read-only fields keep a quieter focus state so they don't read as editable.

```tsx
"use client";

import * as React from "react";
import { Check, Copy } from "lucide-react";
import { Input } from "@/components/ui/input";

const apiKey = "nk_live_51Hx9Q2eZvKYlo2C8Nf3";

export default function InputStates() {
  const [copied, setCopied] = React.useState(false);

  async function copy() {
    await navigator.clipboard.writeText(apiKey);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  }

  return (
    <div className="grid w-full max-w-sm gap-5">
      <Input
        label="Secret key"
        description="Read-only. Roll the key from the dashboard if it leaks."
        value={apiKey}
        readOnly
        className="font-mono"
        onFocus={(event) => event.currentTarget.select()}
        trailing={
          <button
            type="button"
            onClick={copy}
            aria-label={copied ? "Copied" : "Copy secret key"}
            className="-mr-1.5 grid size-7 cursor-pointer place-items-center rounded-md text-white/40 outline-none transition-colors hover:text-white/80 focus-visible:bg-[rgb(var(--neon)/0.12)] focus-visible:text-[rgb(var(--neon))]"
          >
            {copied ? <Check className="text-[#34d399]" /> : <Copy />}
          </button>
        }
      />
      <Input
        label="Billing email"
        description="Managed by your organization owner."
        defaultValue="billing@northwind.io"
        disabled
      />
    </div>
  );
}
```

### Sizes

Heights match Button: 32, 40 and 48px.

```tsx
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";

export default function InputSizes() {
  return (
    <div className="grid w-full max-w-sm gap-3">
      <Input size="sm" aria-label="Small" placeholder="Small" leading={<Search />} />
      <Input size="md" aria-label="Medium" placeholder="Medium" leading={<Search />} />
      <Input size="lg" aria-label="Large" placeholder="Large" leading={<Search />} />
    </div>
  );
}
```

## API reference

### Input

Also accepts every native input attribute, and the ref points at the <input>. className and style go to the field box around it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` | — | Visible label, linked to the input. |
| `description` | `ReactNode` | — | Helper text under the field. |
| `error` | `ReactNode` | — | Red invalid state and message. Replaces description. Pass true for the state without a message. |
| `leading` | `ReactNode` | — | Icon or text addon before the value. Icons light up on focus. |
| `trailing` | `ReactNode` | — | Icon, hint or small button after the value. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Height, padding and text size. |
| `className` | `string` | — | Classes for the field box: border, background, height. |
| `containerClassName` | `string` | — | Classes for the outer block with the label and message. Use it for width and margins. |

## Accessibility

- The label is a native <label>, and the description or error is linked with aria-describedby.
- Clicking the padding or the leading icon focuses the input, like a native field.
- Fields without a visible label need an aria-label.
- Buttons inside trailing need their own aria-label and stay in the tab order after the input.

## Source

`components/ui/input.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const INVALID = "#ff4d5e";

const sizes = {
  sm: {
    field: "h-8 rounded-md text-xs",
    input: "px-2.5",
    leading: "pl-2.5 [&_svg]:size-3.5",
    trailing: "pr-2.5 [&_svg]:size-3.5",
    afterLeading: "pl-1.5",
    beforeTrailing: "pr-1.5",
    label: "text-xs",
  },
  md: {
    field: "h-10 rounded-lg text-sm",
    input: "px-3",
    leading: "pl-3 [&_svg]:size-4",
    trailing: "pr-3 [&_svg]:size-4",
    afterLeading: "pl-2",
    beforeTrailing: "pr-2",
    label: "text-sm",
  },
  lg: {
    field: "h-12 rounded-xl text-base",
    input: "px-4",
    leading: "pl-4 [&_svg]:size-[18px]",
    trailing: "pr-4 [&_svg]:size-[18px]",
    afterLeading: "pl-2.5",
    beforeTrailing: "pr-2.5",
    label: "text-sm",
  },
} as const;

export interface InputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "color" | "size"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Icon or text addon inside the field, before the value. */
  leading?: React.ReactNode;
  /** Icon, hint or small button inside the field, after the value. */
  trailing?: React.ReactNode;
  label?: React.ReactNode;
  /** Helper text under the field. Hidden while `error` is set. */
  description?: React.ReactNode;
  /** Marks the field invalid and shows this message. Pass `true` for the red state alone. */
  error?: React.ReactNode;
  /** Classes for the field box: the bordered wrapper around the input. */
  className?: string;
  /** Classes for the outer block holding the label, field and message. */
  containerClassName?: string;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  (
    {
      className,
      containerClassName,
      style,
      color,
      size = "md",
      leading,
      trailing,
      label,
      description,
      error,
      id: idProp,
      disabled,
      readOnly,
      "aria-describedby": ariaDescribedBy,
      ...props
    },
    ref,
  ) => {
    const inputRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);

    const autoId = React.useId();
    const id = idProp ?? autoId;
    const invalid = Boolean(error);
    const message = invalid ? (error === true ? null : error) : description;
    const messageId = message ? `${id}-${invalid ? "error" : "description"}` : undefined;
    const s = sizes[size];

    // Clicks on the padding or icons land in the input, like a native field.
    function handleMouseDown(event: React.MouseEvent<HTMLDivElement>) {
      const target = event.target as HTMLElement;
      if (target === inputRef.current) return;
      if (target.closest("button, a, input, select, textarea, [tabindex]")) return;
      event.preventDefault();
      inputRef.current?.focus();
    }

    return (
      <div className={cn("grid w-full gap-1.5", containerClassName)}>
        {label && (
          <label htmlFor={id} className={cn("w-fit font-medium text-white/80", s.label)}>
            {label}
          </label>
        )}
        <div
          style={neonVars(invalid ? INVALID : color, style)}
          onMouseDown={handleMouseDown}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "group/field relative flex w-full cursor-text 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]",
            "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)/0.5),inset_0_0_12px_-2px_rgb(var(--neon)/0.14)]",
            !disabled && "hover:[&:not(:focus-within)]:border-white/20",
            invalid && "border-[rgb(var(--neon)/0.55)] hover:[&:not(:focus-within)]:border-[rgb(var(--neon)/0.75)]",
            readOnly &&
              "border-white/[0.08] bg-transparent text-white/70 focus-within:border-[rgb(var(--neon)/0.45)] focus-within:bg-transparent focus-within:shadow-none",
            disabled && "cursor-not-allowed opacity-50",
            s.field,
            className,
          )}
        >
          {leading && (
            <span
              className={cn(
                "flex shrink-0 items-center text-white/40 [&_svg]:transition-colors [&_svg]:duration-200 group-focus-within/field:[&_svg]:text-[rgb(var(--neon)/0.9)]",
                s.leading,
              )}
            >
              {leading}
            </span>
          )}
          <input
            ref={inputRef}
            id={id}
            disabled={disabled}
            readOnly={readOnly}
            aria-invalid={invalid || undefined}
            aria-describedby={[ariaDescribedBy, messageId].filter(Boolean).join(" ") || undefined}
            className={cn(
              "h-full w-full min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-white/30",
              "disabled:cursor-not-allowed [&::-webkit-search-cancel-button]:appearance-none",
              "[&:-webkit-autofill]:[-webkit-text-fill-color:rgb(255_255_255/0.9)] [&:-webkit-autofill]:[transition:background-color_9999s_ease-out]",
              s.input,
              leading && s.afterLeading,
              trailing && s.beforeTrailing,
            )}
            {...props}
          />
          {trailing && (
            <span className={cn("flex shrink-0 items-center text-white/40", s.trailing)}>
              {trailing}
            </span>
          )}
        </div>
        {message && (
          <p
            id={messageId}
            className={cn("text-xs leading-5", invalid ? "text-[#ff4d5e]" : "text-white/55")}
          >
            {message}
          </p>
        )}
      </div>
    );
  },
);
Input.displayName = "Input";

export { Input };
```
