Inputs & Forms

Input

A text input with top or floating label, token-driven focus ring, and orthogonal error/success states so the shake replays without flickering the error styling.

Install

npx shadcn@latest add @paragon/input

input.tsx

"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface InputProps extends React.ComponentProps<"input"> {
  /** Visible label, wired via htmlFor. */
  label?: string;
  /** "top" places the label above the field; "float" floats it inside. */
  labelVariant?: "top" | "float";
  /** Helper text below the field. Hidden while an error is shown. */
  hint?: string;
  /** Error message. Turns the field red, fades the message in, shakes once. */
  error?: string;
  /** Success state. Pass a string to also show a confirmation message. */
  success?: boolean | string;
  /**
   * Change this value to replay the shake even when the error message is
   * unchanged (e.g. increment on every failed submit).
   */
  shakeKey?: string | number;
}

/**
 * Text input with top or floating label, token-driven focus ring, and
 * orthogonal error/success states. The shake runs as a one-shot keyframe on a
 * class of its own, so replaying it never re-triggers the error styling or
 * the message fade-in.
 */
export function Input({
  label,
  labelVariant = "top",
  hint,
  error,
  success,
  shakeKey,
  className,
  id: idProp,
  placeholder,
  disabled,
  "aria-describedby": ariaDescribedBy,
  ...props
}: InputProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const messageId = `${id}-message`;

  const [shaking, setShaking] = React.useState(false);
  React.useEffect(() => {
    if (error) setShaking(true);
  }, [error, shakeKey]);

  const invalid = Boolean(error);
  const successful = !invalid && Boolean(success);
  const message = error ?? (typeof success === "string" ? success : undefined);
  const floating = labelVariant === "float";

  const input = (
    <input
      id={id}
      disabled={disabled}
      // A placeholder is required for the :placeholder-shown float trigger.
      placeholder={floating ? (placeholder ?? " ") : placeholder}
      aria-invalid={invalid || undefined}
      aria-describedby={
        cn(message || hint ? messageId : undefined, ariaDescribedBy) ||
        undefined
      }
      className={cn(
        "peer h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 text-sm text-foreground",
        "transition-[border-color,box-shadow] duration-150 ease-out",
        "placeholder:text-muted-foreground",
        "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
        "disabled:cursor-not-allowed disabled:opacity-50",
        floating && "placeholder:text-transparent",
        invalid &&
          "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
        successful &&
          "pr-9 border-emerald-600/60 focus-visible:border-emerald-600/80 focus-visible:ring-emerald-600/20 dark:border-emerald-400/50 dark:focus-visible:border-emerald-400/70 dark:focus-visible:ring-emerald-400/20",
        className,
      )}
      {...props}
    />
  );

  return (
    <div className="w-full">
      <style href="paragon-input" precedence="paragon">{`
        @keyframes paragon-input-shake {
          0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
          25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
          100% { translate: 0; }
        }
        @keyframes paragon-input-message-in {
          from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-input-shake { animation: none !important; }
          .paragon-input-message { animation: none !important; }
        }
      `}</style>

      {label && !floating && (
        <label
          htmlFor={id}
          className="mb-1.5 block text-sm font-medium text-foreground"
        >
          {label}
        </label>
      )}

      <div
        className={cn("relative", shaking && "paragon-input-shake")}
        style={
          shaking
            ? { animation: "paragon-input-shake 280ms both" }
            : undefined
        }
        onAnimationEnd={(event) => {
          if (event.animationName === "paragon-input-shake") setShaking(false);
        }}
      >
        {input}
        {label && floating && (
          <label
            htmlFor={id}
            className={cn(
              "pointer-events-none absolute top-1/2 left-3 origin-left -translate-y-1/2 rounded-sm bg-background px-1 text-sm text-muted-foreground",
              "transition-[translate,scale,color] duration-150 ease-out",
              "peer-focus:translate-y-[calc(-50%_-_18px)] peer-focus:scale-[0.85]",
              "peer-[:not(:placeholder-shown)]:translate-y-[calc(-50%_-_18px)] peer-[:not(:placeholder-shown)]:scale-[0.85]",
              "peer-focus:text-foreground",
              "motion-reduce:transition-[color]",
              invalid && "text-destructive peer-focus:text-destructive",
              disabled && "opacity-50",
            )}
          >
            {label}
          </label>
        )}
        {successful && (
          <Check
            aria-hidden
            className="paragon-input-message pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-emerald-600 dark:text-emerald-400"
            style={{
              animation: "paragon-input-message-in 200ms var(--ease-out) both",
            }}
          />
        )}
      </div>

      {(message || hint) && (
        <p
          id={messageId}
          role={invalid ? "alert" : undefined}
          // Keyed so a new message re-runs the fade-in; the shake is a
          // separate class on the field wrapper, so replaying it never
          // flickers this element.
          key={message ?? "hint"}
          className={cn(
            "paragon-input-message mt-1.5 text-[13px]",
            invalid
              ? "text-destructive"
              : successful && message
                ? "text-emerald-600 dark:text-emerald-400"
                : "text-muted-foreground",
          )}
          style={
            message
              ? {
                  animation:
                    "paragon-input-message-in 200ms var(--ease-out) both",
                }
              : undefined
          }
        >
          {message ?? hint}
        </p>
      )}
    </div>
  );
}