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.
npx shadcn@latest add @paragon/input"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>
);
}