Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/animated-input.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
animated-input.tsx. It's yours: change anything.
Usage
"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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Visible label. Floats up when the field is focused or filled. |
| description | string | — | Helper text below the field. |
| error | string | — | Error message. Sets aria-invalid and replaces the description; the field shakes once. |
| valid | boolean | false | Draw a tick at the end of the field. |
| hints | string[] | — | Examples that take turns in the empty, focused field. Decorative. |
| vanish | boolean | false | With a controlled value: cleared text drifts away letter by letter. |
| showCount | boolean | false | Live character count; needs maxLength. |
| containerClassName | string | — | Class for the outer wrapper; className goes to the <input>. |
| --input-accent | CSS variable | --ring | Colour of the focus glow, line and floating label. |
| ...props | ComponentProps<"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
- 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.