{"slug":"input","name":"Input","description":"Text input with label, left/right icons, optional stable error row, error shake and success check draw.","category":"motion","source_url":"https://beui.dev/r/input/raw","detail_url":"https://beui.dev/r/input","raw_url":"https://beui.dev/r/input/raw","page_url":"https://beui.dev/components/motion/input","markdown_url":"https://beui.dev/components/motion/input.md","published_at":"2026-06-29","updated_at":"2026-08-28","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["@/components/motion/input","@/lib/utils"],"files":[{"path":"components/motion/input.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/input\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  leftIcon?: string;\n  rightIcon?: string;\n  successIcon?: string;\n  errorMessage?: string;\n};\n\nexport interface InputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> {\n  label?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  /** Reserve one message line so validation does not shift nearby content. */\n  reserveErrorLine?: boolean;\n  success?: boolean;\n  leftIcon?: ReactNode;\n  rightIcon?: ReactNode;\n  className?: string;\n  classNames?: InputClassNames;\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  {\n    label,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onFocus,\n    onBlur,\n    error,\n    reserveErrorLine = false,\n    success,\n    leftIcon,\n    rightIcon,\n    className,\n    classNames,\n    disabled,\n    id: idProp,\n    type,\n    ...rest\n  },\n  ref,\n) {\n  const reactId = useId();\n  const id = idProp ?? reactId;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [focused, setFocused] = useState(false);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" ? error : null;\n\n  // Right edge shows the success check, otherwise the caller's right icon.\n  const rightSlot = success ? null : rightIcon;\n\n  // Shake the field when an error appears.\n  useEffect(() => {\n    if (!fieldRef.current || reduce || !hasError) return;\n    animate(\n      fieldRef.current,\n      { x: [0, -6, 6, -4, 4, -2, 0] },\n      { duration: 0.45 },\n    );\n  }, [hasError, reduce]);\n\n  const handleChange = (next: string) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n    >\n      {label ? (\n        <label\n          htmlFor={id}\n          className={cn(\n            \"px-1 text-sm font-medium text-foreground\",\n            classNames?.label,\n          )}\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={\n          hasError\n            ? \"error\"\n            : success\n              ? \"success\"\n              : focused\n                ? \"focused\"\n                : \"idle\"\n        }\n        className={cn(\n          \"relative h-11 overflow-hidden rounded-full border transition-colors duration-200\",\n          \"border-border\",\n          focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n          hasError && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"opacity-60\",\n          classNames?.field,\n        )}\n      >\n        {leftIcon ? (\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.leftIcon,\n            )}\n          >\n            {leftIcon}\n          </span>\n        ) : null}\n\n        <input\n          ref={ref}\n          id={id}\n          type={type}\n          value={value}\n          disabled={disabled}\n          aria-invalid={hasError || undefined}\n          aria-describedby={errorMessage ? `${id}-error` : undefined}\n          {...rest}\n          onChange={(e) => handleChange(e.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none\",\n            \"placeholder:text-muted-foreground/60\",\n            leftIcon ? \"pl-10\" : \"pl-3.5\",\n            rightSlot || success ? \"pr-10\" : \"pr-3.5\",\n            disabled && \"cursor-not-allowed\",\n            classNames?.input,\n          )}\n        />\n\n        {success ? (\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            className={cn(\n              \"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)\",\n              classNames?.successIcon,\n            )}\n          >\n            <motion.path\n              d=\"M5 12.5l4.5 4.5L19 7.5\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.35, ease: \"easeOut\" }}\n            />\n          </motion.svg>\n        ) : rightSlot ? (\n          <span\n            className={cn(\n              \"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.rightIcon,\n            )}\n          >\n            {rightSlot}\n          </span>\n        ) : null}\n      </div>\n\n      <div className={reserveErrorLine ? \"min-h-4\" : \"contents\"}>\n        <AnimatePresence initial={false}>\n          {errorMessage ? (\n            <motion.p\n              id={`${id}-error`}\n              role=\"alert\"\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              transition={{ duration: 0.2 }}\n              className={cn(\n                \"px-1 text-xs text-destructive\",\n                classNames?.errorMessage,\n              )}\n            >\n              {errorMessage}\n            </motion.p>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n});\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/input.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Eye, EyeOff, Mail, Search } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { Input } from \"@/components/motion/input\";\n\nexport function InputPreview() {\n  const [email, setEmail] = useState(\"\");\n  const [pass, setPass] = useState(\"hunter2\");\n  const [query, setQuery] = useState(\"Ada\");\n  const [show, setShow] = useState(false);\n\n  const emailError =\n    email.length > 0 && !email.includes(\"@\") ? \"Enter a valid email address.\" : undefined;\n\n  return (\n    <div className=\"flex w-full max-w-xs flex-col gap-1\">\n      <Input\n        label=\"Email\"\n        type=\"email\"\n        placeholder=\"you@example.com\"\n        leftIcon={<Mail />}\n        value={email}\n        onChange={setEmail}\n        error={emailError}\n        reserveErrorLine\n      />\n      <Input\n        label=\"Password\"\n        type={show ? \"text\" : \"password\"}\n        value={pass}\n        onChange={setPass}\n        rightIcon={\n          <button\n            type=\"button\"\n            onClick={() => setShow((s) => !s)}\n            aria-label={show ? \"Hide password\" : \"Show password\"}\n            className=\"pointer-events-auto\"\n          >\n            {show ? <EyeOff /> : <Eye />}\n          </button>\n        }\n        reserveErrorLine\n      />\n      <Input\n        label=\"Search\"\n        leftIcon={<Search />}\n        value={query}\n        onChange={setQuery}\n        success={query.length > 1}\n        reserveErrorLine\n      />\n    </div>\n  );\n}\n"}]}