Skip to content
Uxelith
Sign in
Freev1.1.0Forms

Animated Input

A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

pnpm dlx shadcn add …/r/animated-input.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/animated-input.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as animated-input.tsx. It's yours: change anything.

Usage

example.tsx
"use client"

import { useState } from "react"

import { AnimatedInput } from "@/components/animated-input"

export function Ask() {
  const [message, setMessage] = useState("")
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        setMessage("")
      }}
    >
      <AnimatedInput
        label="Ask anything"
        hints={["Summarise last week's sales", "Find invoices over $5,000"]}
        vanish
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
    </form>
  )
}

Source

One file, 295 lines, nothing hidden. Read it before you install it.

animated-input.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface AnimatedInputProps extends Omit<React.ComponentProps<"input">, "placeholder" | "size"> {
  /** Visible label. It sits inside the field and floats up when the field is focused or filled. */
  label: string;
  /** Helper text under the field. */
  description?: string;
  /** Error message. Marks the field invalid, shakes the field once and is announced with it. */
  error?: string;
  /** Mark the value as good: a tick draws in at the end of the field. */
  valid?: boolean;
  /** Example inputs that take turns in the empty, focused field ("Try: …"). Decorative: put anything essential in `description`. */
  hints?: string[];
  /** With a controlled `value`: when it is cleared (say, after sending), the old text drifts away letter by letter. */
  vanish?: boolean;
  /** Show a character count; needs `maxLength`. */
  showCount?: boolean;
  containerClassName?: string;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const EASE_OUT = [0.22, 1, 0.36, 1] as const;

const HINT_INTERVAL = 2400;

/**
 * A text field with a label that floats out of the way, and the small
 * moments that make a form feel alive: the focus line opens from where you
 * clicked, examples take turns while it's empty, an error shakes the field
 * once, a valid value draws a tick, and a sent message drifts away.
 *
 * - The label is a real `<label>`, so clicking it focuses the field and screen
 *   readers always announce it. There is no placeholder to stand in for it.
 * - `description` and `error` are linked with `aria-describedby`;
 *   an error also sets `aria-invalid`. Hints and the vanishing text are
 *   decorative and hidden from assistive technology.
 * - Accent colour: `--input-accent` (default `--ring`), set with
 *   `containerClassName` or on any parent. Errors always use `--destructive`.
 * - Under reduced motion everything changes in place: no shake, no drift,
 *   and the first hint stays put.
 */
export function AnimatedInput({
  label,
  description,
  error,
  valid = false,
  hints,
  vanish = false,
  showCount = false,
  id: idProp,
  className,
  containerClassName,
  disabled,
  value,
  defaultValue,
  maxLength,
  onChange,
  onFocus,
  onBlur,
  onPointerDown,
  "aria-describedby": describedByProp,
  ...props
}: AnimatedInputProps) {
  const generatedId = React.useId();
  const id = idProp ?? generatedId;
  const reduceMotion = useReducedMotion();
  const field = React.useRef<HTMLDivElement>(null);
  // The description is replaced by the error while one is shown.
  const descriptionId = description && !error ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [describedByProp, descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  const [focused, setFocused] = React.useState(false);
  const [ownValue, setOwnValue] = React.useState(String(defaultValue ?? ""));
  const text = value !== undefined ? String(value) : ownValue;
  const filled = text.length > 0;
  const [origin, setOrigin] = React.useState(0.5);

  // When a controlled value is cleared, keep the old text to blow it away.
  const [previous, setPrevious] = React.useState(text);
  const [ghost, setGhost] = React.useState<{ text: string; key: number } | null>(null);
  if (text !== previous) {
    setPrevious(text);
    if (vanish && !text && previous && !reduceMotion) setGhost((g) => ({ text: previous, key: (g?.key ?? 0) + 1 }));
    else if (text) setGhost(null);
  }

  // Hints take turns while the field is focused and empty.
  const [hint, setHint] = React.useState(0);
  // They wait while sent text is still drifting away.
  const showHints = Boolean(hints?.length) && focused && !filled && !ghost;
  React.useEffect(() => {
    if (!showHints || reduceMotion || (hints?.length ?? 0) < 2) return;
    const timer = setInterval(() => setHint((h) => h + 1), HINT_INTERVAL);
    return () => clearInterval(timer);
  }, [showHints, reduceMotion, hints?.length]);

  // Shake once each time an error appears or changes.
  React.useEffect(() => {
    // The browser's own Web Animations API: a one-off shake needs no library code.
    const shake = error && !reduceMotion ? field.current?.animate?.([0, -6, 6, -4, 4, 0].map((x) => ({ transform: `translateX(${x}px)` })), { duration: 400, easing: "cubic-bezier(0.22,1,0.36,1)" }) : undefined;
    return () => shake?.cancel();
  }, [error, reduceMotion]);

  const floated = focused || filled;
  const instant = reduceMotion ? { duration: 0 } : undefined;
  const count = text.length;
  const nearLimit = maxLength !== undefined && count >= maxLength * 0.9;

  return (
    <div className={cn("grid w-full gap-1.5", containerClassName)}>
      <div
        ref={field}
        className={cn(
          "group/input relative",
          // No default declared here, so an accent set on any parent reaches the field.
          error ? "[--input-accent-current:var(--destructive)]" : "[--input-accent-current:var(--input-accent,var(--ring))]",
          disabled && "opacity-50",
        )}
      >
        <input
          id={id}
          disabled={disabled}
          value={value}
          defaultValue={defaultValue}
          maxLength={maxLength}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          onChange={(event) => {
            onChange?.(event);
            if (value === undefined) setOwnValue(event.target.value);
          }}
          onFocus={(event) => {
            onFocus?.(event);
            setFocused(true);
            // Autofill can fill the field without a change event.
            if (value === undefined) setOwnValue(event.target.value);
          }}
          onBlur={(event) => {
            onBlur?.(event);
            setFocused(false);
          }}
          onPointerDown={(event) => {
            onPointerDown?.(event);
            const rect = event.currentTarget.getBoundingClientRect();
            if (rect.width) setOrigin(Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1));
          }}
          className={cn(
            "peer block h-14 w-full rounded-lg border bg-card px-3.5 pb-2 pt-6 text-sm text-foreground outline-none",
            "transition-[border-color,box-shadow] duration-200 motion-reduce:transition-none",
            "disabled:cursor-not-allowed",
            error ? "border-destructive/70" : "border-input hover:border-[color-mix(in_oklab,var(--foreground)_25%,transparent)]",
            "focus:border-[var(--input-accent-current)] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--input-accent-current)_16%,transparent)]",
            valid && "pr-10",
            className,
          )}
          {...props}
        />
        <motion.label
          htmlFor={id}
          initial={false}
          animate={{ y: floated ? -11 : 0, scale: floated ? 0.8 : 1 }}
          transition={instant ?? SPRING_SNAPPY}
          className={cn(
            "pointer-events-none absolute left-3.5 top-[calc(50%-0.625rem)] origin-left text-sm leading-5 text-muted-foreground transition-colors duration-200",
            focused && "text-[var(--input-accent-current)]",
            error && "text-destructive",
          )}
        >
          {label}
        </motion.label>
        {/* Example inputs, in the spot where the text will go. */}
        {hints?.length ? (
          <span aria-hidden="true" className="pointer-events-none absolute inset-x-3.5 top-[1.5625rem] h-5 overflow-hidden text-sm leading-5 text-muted-foreground">
            <AnimatePresence initial={false} mode="popLayout">
              {showHints && (
                <motion.span
                  key={hint % hints.length}
                  className="block truncate"
                  initial={{ opacity: 0, y: 10, filter: "blur(3px)" }}
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  exit={{ opacity: 0, y: -10, filter: "blur(3px)" }}
                  transition={instant ?? SPRING_SMOOTH}
                >
                  Try: {hints[hint % hints.length]}
                </motion.span>
              )}
            </AnimatePresence>
          </span>
        ) : null}
        {/* The sent text, drifting away letter by letter. */}
        {ghost && (
          <span key={ghost.key} aria-hidden="true" className="pointer-events-none absolute inset-x-3.5 top-[1.5625rem] flex h-5 overflow-hidden whitespace-pre text-sm leading-5 text-foreground">
            {Array.from(ghost.text).map((char, i, chars) => (
              <motion.span
                key={i}
                initial={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                animate={{ opacity: 0, y: -8 - (i % 3) * 3, x: (i % 2 ? 1 : -1) * (2 + (i % 4)), filter: "blur(4px)" }}
                transition={{ duration: 0.45, delay: Math.min(i * 0.018, 0.4), ease: EASE_OUT }}
                onAnimationComplete={i === chars.length - 1 ? () => setGhost(null) : undefined}
              >
                {char}
              </motion.span>
            ))}
          </span>
        )}
        {/* Valid: a tick draws in. */}
        <AnimatePresence initial={false}>
          {valid && !error && (
            <motion.svg
              key="valid"
              aria-hidden="true"
              viewBox="0 0 16 16"
              fill="none"
              className="pointer-events-none absolute right-3.5 top-1/2 size-4 -translate-y-1/2 text-[var(--input-accent-current)]"
              initial={{ opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.6 }}
              transition={instant ?? SPRING_SNAPPY}
            >
              <motion.path
                d="m3.5 8.5 3 3 6-7"
                stroke="currentColor"
                strokeWidth="1.75"
                strokeLinecap="round"
                strokeLinejoin="round"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={instant ?? { duration: 0.3, ease: EASE_OUT, delay: 0.05 }}
              />
            </motion.svg>
          )}
        </AnimatePresence>
        {/* The focus line opens from where you clicked. */}
        <motion.span
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-3 bottom-0 h-px bg-[var(--input-accent-current)]"
          style={{ originX: origin }}
          initial={false}
          animate={{ scaleX: focused ? 1 : 0 }}
          transition={instant ?? SPRING_SMOOTH}
        />
      </div>
      {descriptionId || error || (showCount && maxLength !== undefined) ? (
        <div className="flex items-start gap-3 px-1 text-xs">
          <div className="relative min-w-0 flex-1">
            <AnimatePresence initial={false} mode="popLayout">
              {error ? (
                <motion.p
                  key="error"
                  id={errorId}
                  className="flex items-center gap-1.5 font-medium text-destructive"
                  initial={{ opacity: 0, y: -4 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -4 }}
                  transition={instant ?? SPRING_SNAPPY}
                >
                  <svg viewBox="0 0 16 16" className="size-3.5 shrink-0" fill="none" aria-hidden="true">
                    <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
                    <path d="M8 4.75v3.75M8 10.75v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
                  </svg>
                  {error}
                </motion.p>
              ) : descriptionId ? (
                <motion.p
                  key="description"
                  id={descriptionId}
                  className="text-muted-foreground"
                  initial={{ opacity: 0, y: 4 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: 4 }}
                  transition={instant ?? SPRING_SNAPPY}
                >
                  {description}
                </motion.p>
              ) : null}
            </AnimatePresence>
          </div>
          {showCount && maxLength !== undefined && (
            <span aria-hidden="true" className={cn("shrink-0 tabular-nums text-muted-foreground transition-colors duration-200", nearLimit && "text-[var(--input-accent-current)]")}>
              {count}/{maxLength}
            </span>
          )}
        </div>
      ) : null}
    </div>
  );
}

Props

PropTypeDefaultDescription
labelstring—Visible label. Floats up when the field is focused or filled.
descriptionstring—Helper text below the field.
errorstring—Error message. Sets aria-invalid and replaces the description; the field shakes once.
validbooleanfalseDraw a tick at the end of the field.
hintsstring[]—Examples that take turns in the empty, focused field. Decorative.
vanishbooleanfalseWith a controlled value: cleared text drifts away letter by letter.
showCountbooleanfalseLive character count; needs maxLength.
containerClassNamestring—Class for the outer wrapper; className goes to the <input>.
--input-accentCSS variable--ringColour of the focus glow, line and floating label.
...propsComponentProps<"input">—Every native input attribute except placeholder, which the floating label replaces.

Accessibility

  • The label is a real <label> tied to the input, so it is always announced and clicking it focuses the field.
  • No placeholder stands in for the label; hints appear only after focus, beside the floated label, and are hidden from assistive technology.
  • Description and error are linked with aria-describedby; an error also sets aria-invalid.
  • The error is shown with text and an icon, not colour alone.
  • Under prefers-reduced-motion nothing moves: no shake, no drift, and the first hint stays put.

Changelog

  1. v1.1.0Latest

    The focus accent (--input-accent) can now be set on any parent element, not just inside the component. Error state always uses --destructive.

More in Forms

All 10 in Forms
  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.

  • FormsPro

    Multi Select

    Pick several options from a searchable list: chips pop in and slide aside as others leave, matches are highlighted, and creatable turns it into a tag input. Limits, icons and forms.