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-4", 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")}
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")}
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")}
/>
<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")}
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-4", 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")}
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")}
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")}
/>
<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")}
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 { 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;
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,
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>
<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>
);
});
"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-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
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 { 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?any—onValuesChange?((values: SignUpValues) => void)—onSubmit?((values: SignUpValues) => any)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?"idle" | "loading" | "success" | "error"Controlled submit state. Omit to let the form track it.
—errorMessage?stringForm-level failure message, shown above the submit button.
—title?anyCreate your accountdescription?anyStart building in under a minute.submitLabel?stringCreate accountfooter?any—strengthMeter?booleanShow the password strength meter.
trueclassName?string—classNames?SignUpFormClassNames—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