Overlays & Feedbackv0.1.0
Error Shake Field
A crisp shake with a message that slides down.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/error-shake-field.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --destructive-text, which shadcn does not define by default. Add it to your global CSS (--destructive-text falls back to your --destructive unless you set it):
app/globals.css@theme inline { --color-destructive-text: var(--destructive-text, var(--destructive)); }Copy the source
components/ui/error-shake-field/error-shake-field.tsx"use client"; import { useEffect, useId, useRef, type ComponentProps } from "react"; import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react"; import { CircleAlert } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface ErrorShakeFieldProps extends Omit<ComponentProps<"input">, "size"> { /** Visible label above the field. */ label: string; /** Error message. Setting it (or changing shakeKey while it's set) shakes the field. */ error?: string; /** Helper text shown when there is no error. */ description?: string; /** Change this on every failed submit to shake again even if the message is unchanged. */ shakeKey?: number | string; /** Classes for the outer wrapper. */ className?: string; } // The classic "nope" curve: a quick start that decays into small, damped wiggles. const SHAKE = { x: [0, -9, 8, -6, 5, -3, 2, 0] }; const SHAKE_EASE = [0.36, 0.07, 0.19, 0.97] as const; export function ErrorShakeField({ label, error, description, shakeKey, className, id, "aria-describedby": describedBy, ...props }: ErrorShakeFieldProps) { const generatedId = useId(); const inputId = id ?? generatedId; const messageId = `${inputId}-message`; const [scope, animate] = useAnimate<HTMLDivElement>(); const reduceMotion = useReducedMotion(); const mounted = useRef(false); const message = error ?? description; useEffect(() => { // Don't shake on first render; only in response to a new error or attempt. if (!mounted.current) { mounted.current = true; return; } if (!error || reduceMotion || !scope.current) return; animate(scope.current, SHAKE, { duration: 0.5, ease: SHAKE_EASE }); }, [error, shakeKey, reduceMotion, animate, scope]); return ( <div className={cn("w-full space-y-1.5", className)}> <label htmlFor={inputId} className={cn("block text-sm font-medium transition-colors", error ? "text-destructive-text" : "text-foreground")}> {label} </label> <div ref={scope} className="relative"> <input id={inputId} aria-invalid={error ? true : undefined} aria-describedby={[message ? messageId : null, describedBy].filter(Boolean).join(" ") || undefined} className={cn( "h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground shadow-xs outline-none", "transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", error ? "border-destructive pr-10 ring-3 ring-destructive/15 focus-visible:ring-destructive/25" : "border-input focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20", )} {...props} /> <AnimatePresence> {error ? ( <motion.span aria-hidden="true" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.5 }} transition={reduceMotion ? { duration: duration.quick } : { type: "spring", stiffness: 500, damping: 22 }} className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-destructive" > <CircleAlert className="size-4" /> </motion.span> ) : null} </AnimatePresence> </div> {/* 0fr → 1fr grid rows reveal the message without animating height directly. */} <div className={cn( "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", message ? "grid-rows-[1fr]" : "grid-rows-[0fr]", )} > <div className="overflow-hidden"> <div id={messageId} aria-live="polite" className="relative"> <AnimatePresence mode="popLayout" initial={false}> <motion.p key={error ? `error:${error}` : `hint:${description ?? ""}`} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.base, ease: ease.out }} className={cn("text-[13px] leading-5", error ? "text-destructive-text" : "text-muted-foreground")} > {message} </motion.p> </AnimatePresence> </div> </div> </div> </div> ); }
Usage
import { ErrorShakeField } from "@/components/ui/error-shake-field/error-shake-field";
export function EmailField({ error, attempt }: { error?: string; attempt: number }) {
return <ErrorShakeField label="Email" name="email" type="email" error={error} shakeKey={attempt} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | – | Visible label above the field. |
error | string | – | Error message. Setting it (or changing shakeKey while it's set) shakes the field. |
description | string | – | Helper text shown when there is no error. |
shakeKey | number | string | – | Change this on every failed submit to shake again even if the message is unchanged. |
className | string | – | Classes for the outer wrapper. |
Also accepts every native <input> attribute, including ref and className.
- The shake uses keyframes eased with cubic-bezier(0.36, 0.07, 0.19, 0.97): fast at first, then decaying wiggles.
- Pass a shakeKey that changes on every failed submit (for example an attempt counter) so repeated mistakes shake again.
- The message area opens with a 0fr → 1fr grid-rows transition, so nothing below jumps.
Accessibility
- A real <label> names the input; the error or hint is linked with aria-describedby and read politely when it changes.
- Errors set aria-invalid and are always shown as text with an icon, never by the red border alone.
- The field never shakes on first render, only in response to a new error or a new attempt.
- With reduced motion enabled, nothing shakes and the message fades in place; the error state is still fully visible.