Sign Up Form
Composed sign-up form that flags a field only once it is left, then clears the moment it is fixed, with a length-weighted strength meter, password reveal and an animated submit lifecycle.
Preview
"use client";
import { useState } from "react";
import { SignUpForm } from "@/components/motion/signup-form";
export function SignUpFormPreview() {
const [formError, setFormError] = useState<string>();
return (
<div className="flex w-full justify-center py-4">
<SignUpForm
description="Sign up with taken@example.com to see the failure state."
errorMessage={formError}
onSubmit={async (values) => {
setFormError(undefined);
await new Promise((resolve) => setTimeout(resolve, 1200));
if (values.email.toLowerCase().startsWith("taken@")) {
setFormError("That email is already registered.");
throw new Error("Email already registered");
}
}}
footer={
<>
Already have an account?{" "}
<button
type="button"
className="font-medium text-foreground underline underline-offset-4"
>
Sign in
</button>
</>
}
/>
</div>
);
}
"use client";
// beui.dev/components/blocks/signup-form
import { Eye, EyeOff, Lock, Mail, User } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type FormEvent,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { StatefulButton } from "@/components/motion/button";
import { Checkbox } from "@/components/motion/checkbox";
import { Input } from "@/components/motion/input";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type SignUpStatus = "idle" | "loading" | "success" | "error";
export type SignUpValues = {
name: string;
email: string;
password: string;
confirmPassword: string;
terms: boolean;
};
export type SignUpErrors = Partial<Record<keyof SignUpValues, string>>;
export type SignUpFormClassNames = {
root?: string;
header?: string;
title?: string;
description?: string;
fields?: string;
strength?: string;
terms?: string;
submit?: string;
footer?: string;
};
export interface SignUpFormProps {
/** Controlled values. Omit for uncontrolled. */
values?: SignUpValues;
defaultValues?: Partial<SignUpValues>;
onValuesChange?: (values: SignUpValues) => void;
/** Called with valid values only. Return a promise to drive the button state. */
onSubmit?: (values: SignUpValues) => void | Promise<void>;
/** Replace the built-in rules — return a message per invalid field. */
validate?: (values: SignUpValues) => SignUpErrors;
/** Controlled submit state. Omit to let the form track it. */
status?: SignUpStatus;
/** Form-level failure message, shown above the submit button. */
errorMessage?: string;
title?: ReactNode;
description?: ReactNode;
submitLabel?: string;
footer?: ReactNode;
/** Show the password strength meter. */
strengthMeter?: boolean;
className?: string;
classNames?: SignUpFormClassNames;
}
const EMPTY_VALUES: SignUpValues = {
name: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
};
// Deliberately permissive. Full RFC 5322 matching is impractical in a regex and
// rejects addresses that deliver fine; the only real check is sending mail.
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const STRENGTH_LABELS = ["Too short", "Weak", "Fair", "Good", "Strong"] as const;
const STRENGTH_COLORS = [
"bg-destructive",
"bg-destructive",
"bg-amber-500",
"bg-amber-400",
"bg-(--color-success)",
] as const;
/**
* Length-weighted strength score, 0-4. NIST SP 800-63B advises against
* composition requirements and treats length as the dominant factor, so extra
* character classes only nudge the score — they can't rescue a short password.
* This is a heuristic for feedback, not entropy estimation; pair it with a
* breach-list check server-side for anything real.
*/
export function passwordStrength(password: string): number {
if (password.length < MIN_PASSWORD_LENGTH) return 0;
let score = 1;
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((pattern) =>
pattern.test(password),
).length;
if (classes >= 3) score += 1;
return Math.min(score, 4);
}
function defaultValidate(values: SignUpValues): SignUpErrors {
const errors: SignUpErrors = {};
if (!values.name.trim()) {
errors.name = "Enter your name.";
}
if (!values.email.trim()) {
errors.email = "Enter your email.";
} else if (!EMAIL_PATTERN.test(values.email)) {
errors.email = "That doesn't look like an email address.";
}
if (!values.password) {
errors.password = "Choose a password.";
} else if (values.password.length < MIN_PASSWORD_LENGTH) {
errors.password = `Use at least ${MIN_PASSWORD_LENGTH} characters.`;
}
if (!values.confirmPassword) {
errors.confirmPassword = "Confirm your password.";
} else if (values.confirmPassword !== values.password) {
errors.confirmPassword = "Passwords don't match.";
}
if (!values.terms) {
errors.terms = "Accept the terms to continue.";
}
return errors;
}
export function SignUpForm({
values: valuesProp,
defaultValues,
onValuesChange,
onSubmit,
validate,
status: statusProp,
errorMessage,
title = "Create your account",
description = "Start building in under a minute.",
submitLabel = "Create account",
footer,
strengthMeter = true,
className,
classNames,
}: SignUpFormProps) {
const reduce = useReducedMotion();
const baseId = useId();
const controlled = valuesProp !== undefined;
const [internalValues, setInternalValues] = useState<SignUpValues>({
...EMPTY_VALUES,
...defaultValues,
});
const values = controlled ? valuesProp : internalValues;
const [internalStatus, setInternalStatus] = useState<SignUpStatus>("idle");
const status = statusProp ?? internalStatus;
const [revealPassword, setRevealPassword] = useState(false);
// "Reward early, punish late": errors are computed on every change, but a
// field only *shows* its error once it has been blurred (or submit touched
// everything). So a first entry is never flagged mid-typing, while a field
// already in error clears the moment it becomes valid.
const [touched, setTouched] = useState<Partial<Record<keyof SignUpValues, boolean>>>(
{},
);
const errors = useMemo(
() => (validate ?? defaultValidate)(values),
[values, validate],
);
const setValue = useCallback(
<K extends keyof SignUpValues>(key: K, next: SignUpValues[K]) => {
const nextValues = { ...values, [key]: next };
if (!controlled) {
setInternalValues(nextValues);
if (statusProp === undefined) {
setInternalStatus((current) =>
current === "success" || current === "error" ? "idle" : current,
);
}
}
onValuesChange?.(nextValues);
},
[controlled, onValuesChange, statusProp, values],
);
const touch = useCallback((key: keyof SignUpValues) => {
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
}, []);
/** Error to render for a field — hidden until the field has been touched. */
const shownError = (key: keyof SignUpValues) =>
touched[key] ? errors[key] : undefined;
/** Success check draws only once a touched field is non-empty and valid. */
const isValid = (key: keyof SignUpValues) =>
Boolean(touched[key]) && !errors[key] && Boolean(values[key]);
const strength = passwordStrength(values.password);
const showStrength = strengthMeter && values.password.length > 0;
const isSubmitting = status === "loading";
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTouched({
name: true,
email: true,
password: true,
confirmPassword: true,
terms: true,
});
if (Object.keys(errors).length > 0) return;
if (!onSubmit) return;
if (statusProp === undefined) setInternalStatus("loading");
try {
await onSubmit(values);
if (statusProp === undefined) setInternalStatus("success");
} catch {
if (statusProp === undefined) setInternalStatus("error");
}
};
const termsErrorId = `${baseId}-terms-error`;
const formErrorId = `${baseId}-form-error`;
return (
<form
noValidate
onSubmit={handleSubmit}
className={cn(
"flex w-full max-w-sm flex-col gap-5 rounded-3xl border border-border p-6",
className,
classNames?.root,
)}
>
{title || description ? (
<div className={cn("flex flex-col gap-1", classNames?.header)}>
{title ? (
<h2
className={cn(
"text-xl font-semibold tracking-tight text-foreground",
classNames?.title,
)}
>
{title}
</h2>
) : null}
{description ? (
<p
className={cn(
"text-sm text-muted-foreground",
classNames?.description,
)}
>
{description}
</p>
) : null}
</div>
) : null}
<div className={cn("flex flex-col gap-1", classNames?.fields)}>
<Input
label="Name"
autoComplete="name"
placeholder="Ada Lovelace"
leftIcon={<User />}
disabled={isSubmitting}
value={values.name}
onChange={(next) => setValue("name", next)}
onBlur={() => touch("name")}
error={shownError("name")}
reserveErrorLine
success={isValid("name")}
/>
<Input
label="Email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
leftIcon={<Mail />}
disabled={isSubmitting}
value={values.email}
onChange={(next) => setValue("email", next)}
onBlur={() => touch("email")}
error={shownError("email")}
reserveErrorLine
success={isValid("email")}
/>
<div className="flex flex-col gap-2">
<Input
label="Password"
type={revealPassword ? "text" : "password"}
autoComplete="new-password"
placeholder="At least 8 characters"
leftIcon={<Lock />}
rightIcon={
<button
type="button"
disabled={isSubmitting}
onClick={() => setRevealPassword((prev) => !prev)}
aria-label={revealPassword ? "Hide password" : "Show password"}
className="text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
>
{revealPassword ? <EyeOff /> : <Eye />}
</button>
}
disabled={isSubmitting}
value={values.password}
onChange={(next) => setValue("password", next)}
onBlur={() => touch("password")}
error={shownError("password")}
reserveErrorLine
/>
<AnimatePresence initial={false}>
{showStrength ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("flex flex-col gap-1.5 px-1", classNames?.strength)}
>
<div className="flex gap-1.5" aria-hidden>
{[0, 1, 2, 3].map((index) => (
<span
key={index}
className="h-1 flex-1 overflow-hidden rounded-full bg-muted-foreground/20"
>
{/* scaleX rather than width — transforms only, per the
motion conventions, and it keeps the bar off layout. */}
<motion.span
initial={false}
animate={{ scaleX: index < strength ? 1 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"block h-full w-full origin-left rounded-full",
STRENGTH_COLORS[strength],
)}
/>
</span>
))}
</div>
<p
aria-live="polite"
className="text-xs text-muted-foreground"
>
Password strength: {STRENGTH_LABELS[strength]}
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
<Input
label="Confirm password"
type={revealPassword ? "text" : "password"}
autoComplete="new-password"
placeholder="Re-enter your password"
leftIcon={<Lock />}
disabled={isSubmitting}
value={values.confirmPassword}
onChange={(next) => setValue("confirmPassword", next)}
onBlur={() => touch("confirmPassword")}
error={shownError("confirmPassword")}
reserveErrorLine
success={isValid("confirmPassword")}
/>
</div>
<div className={cn("flex flex-col gap-1.5", classNames?.terms)}>
<Checkbox
checked={values.terms}
disabled={isSubmitting}
onCheckedChange={(next) => {
setValue("terms", next);
touch("terms");
}}
label="I agree to the Terms and Privacy Policy"
aria-describedby={shownError("terms") ? termsErrorId : undefined}
/>
<AnimatePresence initial={false}>
{shownError("terms") ? (
<motion.p
id={termsErrorId}
role="alert"
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className="px-1 text-xs text-destructive"
>
{shownError("terms")}
</motion.p>
) : null}
</AnimatePresence>
</div>
<AnimatePresence initial={false}>
{errorMessage ? (
<motion.p
id={formErrorId}
role="alert"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{errorMessage}
</motion.p>
) : null}
</AnimatePresence>
<StatefulButton
type="submit"
size="lg"
state={status}
loadingText="Creating account"
successText="Account created"
errorText="Try again"
aria-describedby={errorMessage ? formErrorId : undefined}
className={cn("w-full", classNames?.submit)}
>
{submitLabel}
</StatefulButton>
{footer ? (
<div
className={cn(
"text-center text-sm text-muted-foreground",
classNames?.footer,
)}
>
{footer}
</div>
) : null}
</form>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/components/blocks/signup-form
import { Eye, EyeOff, Lock, Mail, User } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type FormEvent,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { StatefulButton } from "@/components/motion/button";
import { Checkbox } from "@/components/motion/checkbox";
import { Input } from "@/components/motion/input";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type SignUpStatus = "idle" | "loading" | "success" | "error";
export type SignUpValues = {
name: string;
email: string;
password: string;
confirmPassword: string;
terms: boolean;
};
export type SignUpErrors = Partial<Record<keyof SignUpValues, string>>;
export type SignUpFormClassNames = {
root?: string;
header?: string;
title?: string;
description?: string;
fields?: string;
strength?: string;
terms?: string;
submit?: string;
footer?: string;
};
export interface SignUpFormProps {
/** Controlled values. Omit for uncontrolled. */
values?: SignUpValues;
defaultValues?: Partial<SignUpValues>;
onValuesChange?: (values: SignUpValues) => void;
/** Called with valid values only. Return a promise to drive the button state. */
onSubmit?: (values: SignUpValues) => void | Promise<void>;
/** Replace the built-in rules — return a message per invalid field. */
validate?: (values: SignUpValues) => SignUpErrors;
/** Controlled submit state. Omit to let the form track it. */
status?: SignUpStatus;
/** Form-level failure message, shown above the submit button. */
errorMessage?: string;
title?: ReactNode;
description?: ReactNode;
submitLabel?: string;
footer?: ReactNode;
/** Show the password strength meter. */
strengthMeter?: boolean;
className?: string;
classNames?: SignUpFormClassNames;
}
const EMPTY_VALUES: SignUpValues = {
name: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
};
// Deliberately permissive. Full RFC 5322 matching is impractical in a regex and
// rejects addresses that deliver fine; the only real check is sending mail.
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const STRENGTH_LABELS = ["Too short", "Weak", "Fair", "Good", "Strong"] as const;
const STRENGTH_COLORS = [
"bg-destructive",
"bg-destructive",
"bg-amber-500",
"bg-amber-400",
"bg-(--color-success)",
] as const;
/**
* Length-weighted strength score, 0-4. NIST SP 800-63B advises against
* composition requirements and treats length as the dominant factor, so extra
* character classes only nudge the score — they can't rescue a short password.
* This is a heuristic for feedback, not entropy estimation; pair it with a
* breach-list check server-side for anything real.
*/
export function passwordStrength(password: string): number {
if (password.length < MIN_PASSWORD_LENGTH) return 0;
let score = 1;
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((pattern) =>
pattern.test(password),
).length;
if (classes >= 3) score += 1;
return Math.min(score, 4);
}
function defaultValidate(values: SignUpValues): SignUpErrors {
const errors: SignUpErrors = {};
if (!values.name.trim()) {
errors.name = "Enter your name.";
}
if (!values.email.trim()) {
errors.email = "Enter your email.";
} else if (!EMAIL_PATTERN.test(values.email)) {
errors.email = "That doesn't look like an email address.";
}
if (!values.password) {
errors.password = "Choose a password.";
} else if (values.password.length < MIN_PASSWORD_LENGTH) {
errors.password = `Use at least ${MIN_PASSWORD_LENGTH} characters.`;
}
if (!values.confirmPassword) {
errors.confirmPassword = "Confirm your password.";
} else if (values.confirmPassword !== values.password) {
errors.confirmPassword = "Passwords don't match.";
}
if (!values.terms) {
errors.terms = "Accept the terms to continue.";
}
return errors;
}
export function SignUpForm({
values: valuesProp,
defaultValues,
onValuesChange,
onSubmit,
validate,
status: statusProp,
errorMessage,
title = "Create your account",
description = "Start building in under a minute.",
submitLabel = "Create account",
footer,
strengthMeter = true,
className,
classNames,
}: SignUpFormProps) {
const reduce = useReducedMotion();
const baseId = useId();
const controlled = valuesProp !== undefined;
const [internalValues, setInternalValues] = useState<SignUpValues>({
...EMPTY_VALUES,
...defaultValues,
});
const values = controlled ? valuesProp : internalValues;
const [internalStatus, setInternalStatus] = useState<SignUpStatus>("idle");
const status = statusProp ?? internalStatus;
const [revealPassword, setRevealPassword] = useState(false);
// "Reward early, punish late": errors are computed on every change, but a
// field only *shows* its error once it has been blurred (or submit touched
// everything). So a first entry is never flagged mid-typing, while a field
// already in error clears the moment it becomes valid.
const [touched, setTouched] = useState<Partial<Record<keyof SignUpValues, boolean>>>(
{},
);
const errors = useMemo(
() => (validate ?? defaultValidate)(values),
[values, validate],
);
const setValue = useCallback(
<K extends keyof SignUpValues>(key: K, next: SignUpValues[K]) => {
const nextValues = { ...values, [key]: next };
if (!controlled) {
setInternalValues(nextValues);
if (statusProp === undefined) {
setInternalStatus((current) =>
current === "success" || current === "error" ? "idle" : current,
);
}
}
onValuesChange?.(nextValues);
},
[controlled, onValuesChange, statusProp, values],
);
const touch = useCallback((key: keyof SignUpValues) => {
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
}, []);
/** Error to render for a field — hidden until the field has been touched. */
const shownError = (key: keyof SignUpValues) =>
touched[key] ? errors[key] : undefined;
/** Success check draws only once a touched field is non-empty and valid. */
const isValid = (key: keyof SignUpValues) =>
Boolean(touched[key]) && !errors[key] && Boolean(values[key]);
const strength = passwordStrength(values.password);
const showStrength = strengthMeter && values.password.length > 0;
const isSubmitting = status === "loading";
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTouched({
name: true,
email: true,
password: true,
confirmPassword: true,
terms: true,
});
if (Object.keys(errors).length > 0) return;
if (!onSubmit) return;
if (statusProp === undefined) setInternalStatus("loading");
try {
await onSubmit(values);
if (statusProp === undefined) setInternalStatus("success");
} catch {
if (statusProp === undefined) setInternalStatus("error");
}
};
const termsErrorId = `${baseId}-terms-error`;
const formErrorId = `${baseId}-form-error`;
return (
<form
noValidate
onSubmit={handleSubmit}
className={cn(
"flex w-full max-w-sm flex-col gap-5 rounded-3xl border border-border p-6",
className,
classNames?.root,
)}
>
{title || description ? (
<div className={cn("flex flex-col gap-1", classNames?.header)}>
{title ? (
<h2
className={cn(
"text-xl font-semibold tracking-tight text-foreground",
classNames?.title,
)}
>
{title}
</h2>
) : null}
{description ? (
<p
className={cn(
"text-sm text-muted-foreground",
classNames?.description,
)}
>
{description}
</p>
) : null}
</div>
) : null}
<div className={cn("flex flex-col gap-1", classNames?.fields)}>
<Input
label="Name"
autoComplete="name"
placeholder="Ada Lovelace"
leftIcon={<User />}
disabled={isSubmitting}
value={values.name}
onChange={(next) => setValue("name", next)}
onBlur={() => touch("name")}
error={shownError("name")}
reserveErrorLine
success={isValid("name")}
/>
<Input
label="Email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
leftIcon={<Mail />}
disabled={isSubmitting}
value={values.email}
onChange={(next) => setValue("email", next)}
onBlur={() => touch("email")}
error={shownError("email")}
reserveErrorLine
success={isValid("email")}
/>
<div className="flex flex-col gap-2">
<Input
label="Password"
type={revealPassword ? "text" : "password"}
autoComplete="new-password"
placeholder="At least 8 characters"
leftIcon={<Lock />}
rightIcon={
<button
type="button"
disabled={isSubmitting}
onClick={() => setRevealPassword((prev) => !prev)}
aria-label={revealPassword ? "Hide password" : "Show password"}
className="text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
>
{revealPassword ? <EyeOff /> : <Eye />}
</button>
}
disabled={isSubmitting}
value={values.password}
onChange={(next) => setValue("password", next)}
onBlur={() => touch("password")}
error={shownError("password")}
reserveErrorLine
/>
<AnimatePresence initial={false}>
{showStrength ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("flex flex-col gap-1.5 px-1", classNames?.strength)}
>
<div className="flex gap-1.5" aria-hidden>
{[0, 1, 2, 3].map((index) => (
<span
key={index}
className="h-1 flex-1 overflow-hidden rounded-full bg-muted-foreground/20"
>
{/* scaleX rather than width — transforms only, per the
motion conventions, and it keeps the bar off layout. */}
<motion.span
initial={false}
animate={{ scaleX: index < strength ? 1 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"block h-full w-full origin-left rounded-full",
STRENGTH_COLORS[strength],
)}
/>
</span>
))}
</div>
<p
aria-live="polite"
className="text-xs text-muted-foreground"
>
Password strength: {STRENGTH_LABELS[strength]}
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
<Input
label="Confirm password"
type={revealPassword ? "text" : "password"}
autoComplete="new-password"
placeholder="Re-enter your password"
leftIcon={<Lock />}
disabled={isSubmitting}
value={values.confirmPassword}
onChange={(next) => setValue("confirmPassword", next)}
onBlur={() => touch("confirmPassword")}
error={shownError("confirmPassword")}
reserveErrorLine
success={isValid("confirmPassword")}
/>
</div>
<div className={cn("flex flex-col gap-1.5", classNames?.terms)}>
<Checkbox
checked={values.terms}
disabled={isSubmitting}
onCheckedChange={(next) => {
setValue("terms", next);
touch("terms");
}}
label="I agree to the Terms and Privacy Policy"
aria-describedby={shownError("terms") ? termsErrorId : undefined}
/>
<AnimatePresence initial={false}>
{shownError("terms") ? (
<motion.p
id={termsErrorId}
role="alert"
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className="px-1 text-xs text-destructive"
>
{shownError("terms")}
</motion.p>
) : null}
</AnimatePresence>
</div>
<AnimatePresence initial={false}>
{errorMessage ? (
<motion.p
id={formErrorId}
role="alert"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{errorMessage}
</motion.p>
) : null}
</AnimatePresence>
<StatefulButton
type="submit"
size="lg"
state={status}
loadingText="Creating account"
successText="Account created"
errorText="Try again"
aria-describedby={errorMessage ? formErrorId : undefined}
className={cn("w-full", classNames?.submit)}
>
{submitLabel}
</StatefulButton>
{footer ? (
<div
className={cn(
"text-center text-sm text-muted-foreground",
classNames?.footer,
)}
>
{footer}
</div>
) : null}
</form>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type InputClassNames = {
root?: string;
label?: string;
field?: string;
input?: string;
leftIcon?: string;
rightIcon?: string;
successIcon?: string;
errorMessage?: string;
};
export interface InputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "defaultValue" | "onChange"
> {
label?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Truthy error triggers a shake, red border and (if a string) a message. */
error?: string | boolean;
/** Reserve one message line so validation does not shift nearby content. */
reserveErrorLine?: boolean;
success?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
className?: string;
classNames?: InputClassNames;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
value: valueProp,
defaultValue,
onChange,
onFocus,
onBlur,
error,
reserveErrorLine = false,
success,
leftIcon,
rightIcon,
className,
classNames,
disabled,
id: idProp,
type,
...rest
},
ref,
) {
const reactId = useId();
const id = idProp ?? reactId;
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? (valueProp ?? "") : internal;
const [focused, setFocused] = useState(false);
const fieldRef = useRef<HTMLDivElement>(null);
const hasError = Boolean(error);
const errorMessage = typeof error === "string" ? error : null;
// Right edge shows the success check, otherwise the caller's right icon.
const rightSlot = success ? null : rightIcon;
// Shake the field when an error appears.
useEffect(() => {
if (!fieldRef.current || reduce || !hasError) return;
animate(
fieldRef.current,
{ x: [0, -6, 6, -4, 4, -2, 0] },
{ duration: 0.45 },
);
}, [hasError, reduce]);
const handleChange = (next: string) => {
if (!controlled) setInternal(next);
onChange?.(next);
};
return (
<div
className={cn("flex flex-col gap-1.5", className, classNames?.root)}
>
{label ? (
<label
htmlFor={id}
className={cn(
"px-1 text-sm font-medium text-foreground",
classNames?.label,
)}
>
{label}
</label>
) : null}
<div
ref={fieldRef}
data-state={
hasError
? "error"
: success
? "success"
: focused
? "focused"
: "idle"
}
className={cn(
"relative h-11 overflow-hidden rounded-full border transition-colors duration-200",
"border-border",
focused && !hasError && "border-foreground/40 ring-2 ring-ring/40",
hasError && "border-destructive ring-2 ring-destructive/25",
disabled && "opacity-60",
classNames?.field,
)}
>
{leftIcon ? (
<span
className={cn(
"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",
classNames?.leftIcon,
)}
>
{leftIcon}
</span>
) : null}
<input
ref={ref}
id={id}
type={type}
value={value}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={errorMessage ? `${id}-error` : undefined}
{...rest}
onChange={(e) => handleChange(e.target.value)}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
className={cn(
"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none",
"placeholder:text-muted-foreground/60",
leftIcon ? "pl-10" : "pl-3.5",
rightSlot || success ? "pr-10" : "pr-3.5",
disabled && "cursor-not-allowed",
classNames?.input,
)}
/>
{success ? (
<motion.svg
viewBox="0 0 24 24"
fill="none"
className={cn(
"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)",
classNames?.successIcon,
)}
>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
</motion.svg>
) : rightSlot ? (
<span
className={cn(
"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",
classNames?.rightIcon,
)}
>
{rightSlot}
</span>
) : null}
</div>
<div className={reserveErrorLine ? "min-h-4" : "contents"}>
<AnimatePresence initial={false}>
{errorMessage ? (
<motion.p
id={`${id}-error`}
role="alert"
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className={cn(
"px-1 text-xs text-destructive",
classNames?.errorMessage,
)}
>
{errorMessage}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
);
});
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
values?SignUpValuesControlled values. Omit for uncontrolled.
—defaultValues?Partial<SignUpValues>—onValuesChange?((values: SignUpValues) => void)—onSubmit?((values: SignUpValues) => void | Promise<void>)Called with valid values only. Return a promise to drive the button state.
—validate?((values: SignUpValues) => Partial<Record<keyof SignUpValues, string>>)Replace the built-in rules — return a message per invalid field.
—status?"error" | "success" | "loading" | "idle"Controlled submit state. Omit to let the form track it.
—errorMessage?stringForm-level failure message, shown above the submit button.
—title?ReactNodeCreate your accountdescription?ReactNodeStart building in under a minute.submitLabel?stringCreate accountfooter?ReactNode—strengthMeter?booleanShow the password strength meter.
trueclassName?string—classNames?SignUpFormClassNames—Related components
Swipeable List
Mobile-style list rows that swipe left or right to reveal contextual action buttons.
Feedback Widget
Corner trigger that morphs open into a feedback popup with message entry and animated sending, success and retry states.
Project Folder
An interactive project folder that opens its file fan on hover or focus, expands into a focus-managed overlay, then retraces the complete path when closed.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated