# Input

Text field with a visible label, optional icons, an error that shakes once, and a success check that draws in.

**Category:** Inputs  
**Exports:** `Input`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/input.json
```
The file lands at `components/ui/input.tsx`; import from `@/components/ui/input`.

## API — Input
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | — |  |
| `hint` | `ReactNode` | — | Helper text under the field. |
| `error` | `string` | — | Shakes the field once and announces the message. |
| `success` | `boolean` | — | Draws a check on the right. |
| `leading` | `ReactNode` | — | Icon on the left. |
| `...props` | `ComponentProps<"input">` | — |  |

## Example — Input

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

export default function InputDemo({
  label = "Work email",
  placeholder = "name@company.com",
  hint = "We'll send the invite here.",
  disabled = false,
}: {
  label?: string;
  placeholder?: string;
  hint?: string;
  disabled?: boolean;
}) {
  const [email, setEmail] = React.useState("");
  const [touched, setTouched] = React.useState(false);
  const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  const error = touched && email && !valid ? "Enter an email like name@example.com" : undefined;

  return (
    <div className="w-full max-w-sm">
      <Input
        label={label}
        type="email"
        autoComplete="email"
        placeholder={placeholder}
        leading={<Mail />}
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={() => setTouched(true)}
        error={error}
        success={valid}
        hint={hint}
        disabled={disabled}
      />
    </div>
  );
}
```

## Source — components/ui/input.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type InputProps = React.ComponentProps<"input"> & {
  label?: string;
  hint?: React.ReactNode;
  /** Shakes the field once when it appears and announces the message. */
  error?: string;
  /** Draws a check on the right edge. */
  success?: boolean;
  leading?: React.ReactNode;
};

export function Input({ label, hint, error, success, leading, className, id: idProp, disabled, ...props }: InputProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const reduce = useReducedMotion();
  const [scope, animate] = useAnimate<HTMLDivElement>();

  React.useEffect(() => {
    if (error && !reduce && scope.current) {
      animate(scope.current, { x: [0, -7, 6, -4, 3, 0] }, { duration: 0.38, ease: "easeOut" });
    }
  }, [error, reduce, animate, scope]);

  const describedBy = [error ? `${id}-error` : null, hint ? `${id}-hint` : null].filter(Boolean).join(" ") || undefined;

  return (
    <div className={cn("grid w-full gap-1.5", className)}>
      {label && (
        <label htmlFor={id} className="text-sm font-medium">
          {label}
        </label>
      )}
      <div
        ref={scope}
        className={cn(
          "relative flex h-11 items-center rounded-lg border bg-background transition-[border-color,box-shadow] duration-150",
          "focus-within:border-foreground/40 focus-within:ring-[3px] focus-within:ring-ring/25",
          error ? "border-destructive focus-within:border-destructive focus-within:ring-destructive/20" : "border-input",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {leading && <span className="pointer-events-none pl-3.5 text-muted-foreground [&_svg]:size-4">{leading}</span>}
        <input
          id={id}
          disabled={disabled}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          className="h-full min-w-0 flex-1 bg-transparent px-3.5 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed sm:text-sm"
          {...props}
        />
        <AnimatePresence>
          {success && !error && (
            <motion.svg
              key="ok"
              viewBox="0 0 24 24"
              className="mr-3.5 size-[18px] shrink-0 text-success"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.4}
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={{ opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.6 }}
              transition={{ type: "spring", stiffness: 500, damping: 26 }}
              aria-label="Valid"
              role="img"
            >
              <motion.circle cx="12" cy="12" r="9" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35 }} />
              <motion.path d="m8.5 12.5 2.5 2.5 4.5-5" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.25, delay: 0.2 }} />
            </motion.svg>
          )}
        </AnimatePresence>
      </div>
      <AnimatePresence initial={false} mode="popLayout">
        {error ? (
          <motion.p
            key="error"
            id={`${id}-error`}
            role="alert"
            className="flex items-center gap-1.5 text-[13px] text-destructive"
            initial={{ opacity: 0, y: -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.18 }}
          >
            <svg viewBox="0 0 24 24" className="size-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden>
              <circle cx="12" cy="12" r="9" />
              <path d="M12 7.5v5.5M12 16.5h.01" />
            </svg>
            {error}
          </motion.p>
        ) : hint ? (
          <motion.p key="hint" id={`${id}-hint`} className="text-[13px] text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
            {hint}
          </motion.p>
        ) : null}
      </AnimatePresence>
    </div>
  );
}
```