分步表单
New「一次一题」聚焦式多步表单:分段进度条配等宽计数,方向性滑动切换配合容器高度变形;支持文本题与选择卡题,校验失败抖动且错误行预留空间不跳版,Enter 直接推进,完成态画圈打勾。
01/04
INTRO
A first name is fine.
TSXcomponents/previews/blocks/step-form.preview.tsx
"use client";
import { useState } from "react";
import { StepForm, type StepFormStep } from "@/components/motion/step-form";
const DEMO_STEPS: StepFormStep[] = [
{
id: "name",
kind: "text",
eyebrow: "INTRO",
title: "What should we call you?",
hint: "A first name is fine.",
placeholder: "Your name",
required: true,
},
{
id: "team-size",
kind: "choice",
eyebrow: "TEAM",
title: "How big is the crew?",
options: [
{ value: "solo", label: "Solo", description: "Just me for now" },
{ value: "small", label: "Small team", description: "2–10 people" },
{ value: "scaling", label: "Scaling up", description: "More than 10" },
],
},
{
id: "focus",
kind: "choice",
eyebrow: "FOCUS",
title: "What are you building first?",
options: [
{ value: "landing", label: "Landing page" },
{ value: "dashboard", label: "Product dashboard" },
{ value: "mobile", label: "Mobile app" },
],
},
{
id: "email",
kind: "text",
eyebrow: "CONTACT",
title: "Where should updates go?",
inputType: "email",
placeholder: "you@example.com",
validate: (value) => (value.includes("@") ? null : "Enter a valid email."),
},
];
export function StepFormPreview() {
const [key, setKey] = useState(0);
return (
<div className="flex w-full justify-center rounded-2xl bg-[#eef1f5] p-6 sm:p-10">
<StepForm
key={key}
steps={DEMO_STEPS}
successMessage="We'll email you when your workspace is ready."
onComplete={async () => {
await new Promise((resolve) => setTimeout(resolve, 600));
}}
onRestart={() => setKey((k) => k + 1)}
/>
</div>
);
}
TSXcomponents/motion/step-form.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/step-form
import { Loader2 } from "lucide-react";
import {
AnimatePresence,
type AnimationControls,
motion,
useAnimationControls,
useReducedMotion,
type Variants,
} from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/motion/button";
import { Input } from "@/components/motion/input";
import { EASE_OUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StepFormStep =
| {
id: string;
kind: "text";
/** Small mono, uppercase, wide-tracking label above the title. */
eyebrow?: string;
title: string;
hint?: string;
placeholder?: string;
inputType?: "text" | "email";
required?: boolean;
/** Return an error message to block continuing, or null when valid. */
validate?: (value: string) => string | null;
}
| {
id: string;
kind: "choice";
eyebrow?: string;
title: string;
hint?: string;
options: { value: string; label: string; description?: string }[];
required?: boolean;
};
export interface StepFormProps {
steps: StepFormStep[];
onComplete?: (values: Record<string, string>) => void | Promise<void>;
/** Default "All set". */
successTitle?: string;
successMessage?: string;
/** Provide to show a ghost "restart" button on the success screen. */
onRestart?: () => void;
className?: string;
}
type StepDirection = 1 | -1;
type FormStatus = "active" | "submitting" | "success";
/** Horizontal travel for the directional step swap — spec-mandated 24px. */
const STEP_SLIDE_OFFSET = 24;
/** Pause after picking an option, long enough to register the choice before advancing. */
const CHOICE_AUTO_ADVANCE_MS = 250;
const stepVariants: Variants = {
enter: (direction: StepDirection) => ({
opacity: 0,
x: direction * STEP_SLIDE_OFFSET,
}),
center: {
opacity: 1,
x: 0,
transition: { duration: 0.3, ease: EASE_OUT },
},
exit: (direction: StepDirection) => ({
opacity: 0,
x: direction * -STEP_SLIDE_OFFSET,
transition: { duration: 0.18, ease: EASE_OUT },
}),
};
function pad(value: number) {
return String(value).padStart(2, "0");
}
/** Mirrors the shake feel from input.tsx's own error effect. */
function shake(controls: AnimationControls, reduce: boolean) {
if (reduce) return;
controls.start({
x: [0, -6, 6, -4, 4, -2, 0],
transition: { duration: 0.45 },
});
}
function SegmentedProgress({
steps,
activeIndex,
reduce,
}: {
steps: StepFormStep[];
activeIndex: number;
reduce: boolean;
}) {
return (
<div aria-hidden className="flex flex-1 items-center gap-1.5">
{steps.map((step, index) => {
const filled = index <= activeIndex;
return (
<span
key={step.id}
className="h-1 flex-1 overflow-hidden rounded-full bg-border"
>
<motion.span
className="block h-full w-full origin-left rounded-full bg-foreground"
initial={false}
animate={{ scaleX: filled ? 1 : 0 }}
transition={
reduce
? { duration: 0.12 }
: { duration: 0.35, ease: EASE_OUT }
}
/>
</span>
);
})}
</div>
);
}
function SuccessCheck({ reduce }: { reduce: boolean }) {
return (
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-full bg-(--color-success)/12 text-(--color-success)">
<svg viewBox="0 0 48 48" fill="none" className="h-7 w-7" aria-hidden="true">
<motion.circle
cx="24"
cy="24"
r="21"
stroke="currentColor"
strokeWidth={2.5}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5, ease: EASE_OUT }}
/>
<motion.path
d="M14 24.5l6.5 6.5L34 17"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.4,
ease: EASE_OUT,
delay: reduce ? 0 : 0.35,
}}
/>
</svg>
</span>
);
}
/** Shared enter/animate/exit for the two outer views (form ⇄ success). */
function outerViewMotionProps(reduce: boolean) {
return {
initial: reduce
? { opacity: 0 }
: { opacity: 0, y: 8, filter: "blur(4px)" },
animate: reduce
? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }
: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.24, ease: EASE_OUT },
},
exit: reduce
? { opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } }
: {
opacity: 0,
y: -8,
filter: "blur(4px)",
transition: { duration: 0.16, ease: EASE_OUT },
},
} as const;
}
export function StepForm({
steps,
onComplete,
successTitle = "All set",
successMessage,
onRestart,
className,
}: StepFormProps) {
const reduce = useReducedMotion();
const baseId = useId();
const fieldId = `${baseId}-field`;
const errorId = `${baseId}-error`;
const [stepIndex, setStepIndex] = useState(0);
const [direction, setDirection] = useState<StepDirection>(1);
const [values, setValues] = useState<Record<string, string>>({});
const [fieldError, setFieldError] = useState<string | null>(null);
const [status, setStatus] = useState<FormStatus>("active");
const inputRef = useRef<HTMLInputElement>(null);
const choiceShake = useAnimationControls();
const choiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipFocusRef = useRef(true);
const currentStep = steps[stepIndex];
const isLastStep = stepIndex === steps.length - 1;
const currentValue = currentStep ? (values[currentStep.id] ?? "") : "";
const submitting = status === "submitting";
const clearChoiceTimer = () => {
if (choiceTimerRef.current !== null) {
clearTimeout(choiceTimerRef.current);
choiceTimerRef.current = null;
}
};
// Cancel any pending auto-advance if the form unmounts mid-pause.
useEffect(() => {
return () => {
if (choiceTimerRef.current !== null) clearTimeout(choiceTimerRef.current);
};
}, []);
// Focus the field on every step arrival except the very first paint, so
// landing on the form never steals focus but advancing keeps you typing.
// Re-reads `steps[stepIndex]` instead of closing over `currentStep` so a
// run between two consecutive "text" steps still re-triggers on index
// change (kind alone wouldn't differ).
useEffect(() => {
if (skipFocusRef.current) {
skipFocusRef.current = false;
return;
}
const step = steps[stepIndex];
if (status !== "active" || step?.kind !== "text") return;
const timeoutId = setTimeout(
() => inputRef.current?.focus({ preventScroll: true }),
reduce ? 0 : 260,
);
return () => clearTimeout(timeoutId);
}, [stepIndex, status, reduce, steps]);
if (!currentStep) return null;
const goToStep = (nextIndex: number, dir: StepDirection) => {
clearChoiceTimer();
setFieldError(null);
setDirection(dir);
setStepIndex(nextIndex);
};
const goBack = () => {
if (stepIndex === 0) return;
goToStep(stepIndex - 1, -1);
};
const handleComplete = async () => {
setStatus("submitting");
try {
await onComplete?.(values);
setStatus("success");
} catch {
// No error UX is specified for a rejected onComplete — fall back to
// the editable step so the user can retry instead of getting stuck.
setStatus("active");
}
};
const advance = () => {
if (isLastStep) {
void handleComplete();
return;
}
goToStep(stepIndex + 1, 1);
};
const handleValueChange = (next: string) => {
setValues((prev) => ({ ...prev, [currentStep.id]: next }));
setFieldError(null);
};
const handleContinue = () => {
if (submitting) return;
if (currentStep.kind === "text") {
const trimmed = currentValue.trim();
if (currentStep.required && trimmed.length === 0) {
setFieldError("This field is required.");
return;
}
const validationError =
trimmed.length > 0 ? currentStep.validate?.(currentValue) : null;
if (validationError) {
setFieldError(validationError);
return;
}
} else if (currentStep.required && !currentValue) {
setFieldError("Choose an option to continue.");
shake(choiceShake, !!reduce);
return;
}
advance();
};
const handleChoiceSelect = (value: string) => {
if (submitting) return;
handleValueChange(value);
clearChoiceTimer();
choiceTimerRef.current = setTimeout(() => {
choiceTimerRef.current = null;
advance();
}, CHOICE_AUTO_ADVANCE_MS);
};
const titleClassName = cn(
"block text-2xl font-semibold leading-snug text-foreground",
currentStep.eyebrow && "mt-2",
);
return (
<motion.div
layout
transition={SPRING_PANEL}
className={cn(
"w-full max-w-lg overflow-hidden rounded-3xl border border-border bg-card p-6 shadow-sm will-change-transform sm:p-8",
className,
)}
>
<motion.div layout="position">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
{...outerViewMotionProps(!!reduce)}
className="flex flex-col items-center px-2 py-4 text-center"
>
<SuccessCheck reduce={!!reduce} />
<h3 className="mt-5 text-xl font-semibold text-foreground">
{successTitle}
</h3>
{successMessage ? (
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{successMessage}
</p>
) : null}
{onRestart ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-6"
onClick={onRestart}
>
Restart
</Button>
) : null}
</motion.div>
) : (
<motion.div key="form" {...outerViewMotionProps(!!reduce)}>
<div className="flex items-center gap-4">
<SegmentedProgress
steps={steps}
activeIndex={stepIndex}
reduce={!!reduce}
/>
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span className="text-foreground">
{pad(stepIndex + 1)}
</span>
<span className="px-0.5">/</span>
{pad(steps.length)}
</span>
</div>
<div className="mt-8">
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.div
key={currentStep.id}
custom={direction}
variants={stepVariants}
initial={reduce ? { opacity: 0 } : "enter"}
animate={
reduce
? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }
: "center"
}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }
: "exit"
}
>
<div className="flex flex-col gap-2">
{currentStep.eyebrow ? (
<p className="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
{currentStep.eyebrow}
</p>
) : null}
{currentStep.kind === "text" ? (
<label htmlFor={fieldId} className={titleClassName}>
{currentStep.title}
</label>
) : (
<h3 className={titleClassName}>{currentStep.title}</h3>
)}
{currentStep.hint ? (
<p className="text-sm text-muted-foreground">
{currentStep.hint}
</p>
) : null}
</div>
<div className="mt-6">
{currentStep.kind === "text" ? (
<Input
ref={inputRef}
id={fieldId}
type={currentStep.inputType === "email" ? "email" : "text"}
inputMode={currentStep.inputType === "email" ? "email" : "text"}
placeholder={currentStep.placeholder}
value={currentValue}
onChange={handleValueChange}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleContinue();
}
}}
error={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
disabled={submitting}
/>
) : (
<motion.div
animate={choiceShake}
className="flex flex-col gap-2.5"
>
{currentStep.options.map((option) => {
const selected = currentValue === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
disabled={submitting}
onClick={() => handleChoiceSelect(option.value)}
className={cn(
"flex items-center justify-between gap-4 rounded-2xl border px-4 py-3.5 text-left transition-colors disabled:pointer-events-none disabled:opacity-60",
selected
? "border-foreground bg-primary/5"
: "border-border hover:bg-primary/5",
)}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{option.label}
</span>
{option.description ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
{option.description}
</span>
) : null}
</span>
<span
aria-hidden
className={cn(
"grid h-5 w-5 shrink-0 place-items-center rounded-full border",
selected ? "border-foreground" : "border-border",
)}
>
<motion.span
className="h-2 w-2 rounded-full bg-foreground"
initial={false}
animate={{ scale: selected ? 1 : 0 }}
transition={
reduce ? { duration: 0.12 } : SPRING_SWAP
}
/>
</span>
</button>
);
})}
</motion.div>
)}
</div>
<div className="mt-2 min-h-[1.25rem] px-1">
<AnimatePresence initial={false}>
{fieldError ? (
<motion.p
key="error"
id={errorId}
role="alert"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="text-xs text-destructive"
>
{fieldError}
</motion.p>
) : null}
</AnimatePresence>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="mt-8 flex items-center gap-3">
<AnimatePresence initial={false}>
{stepIndex > 0 ? (
<motion.span
key="back"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={submitting}
>
Back
</Button>
</motion.span>
) : null}
</AnimatePresence>
<Button
type="button"
onClick={handleContinue}
disabled={submitting}
className="ml-auto"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Submitting…
</span>
) : isLastStep ? (
"Complete"
) : (
"Continue"
)}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/step-form
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/hooks/use-hover-capable.ts
"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
TSXcomponents/motion/step-form.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/step-form
import { Loader2 } from "lucide-react";
import {
AnimatePresence,
type AnimationControls,
motion,
useAnimationControls,
useReducedMotion,
type Variants,
} from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/motion/button";
import { Input } from "@/components/motion/input";
import { EASE_OUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StepFormStep =
| {
id: string;
kind: "text";
/** Small mono, uppercase, wide-tracking label above the title. */
eyebrow?: string;
title: string;
hint?: string;
placeholder?: string;
inputType?: "text" | "email";
required?: boolean;
/** Return an error message to block continuing, or null when valid. */
validate?: (value: string) => string | null;
}
| {
id: string;
kind: "choice";
eyebrow?: string;
title: string;
hint?: string;
options: { value: string; label: string; description?: string }[];
required?: boolean;
};
export interface StepFormProps {
steps: StepFormStep[];
onComplete?: (values: Record<string, string>) => void | Promise<void>;
/** Default "All set". */
successTitle?: string;
successMessage?: string;
/** Provide to show a ghost "restart" button on the success screen. */
onRestart?: () => void;
className?: string;
}
type StepDirection = 1 | -1;
type FormStatus = "active" | "submitting" | "success";
/** Horizontal travel for the directional step swap — spec-mandated 24px. */
const STEP_SLIDE_OFFSET = 24;
/** Pause after picking an option, long enough to register the choice before advancing. */
const CHOICE_AUTO_ADVANCE_MS = 250;
const stepVariants: Variants = {
enter: (direction: StepDirection) => ({
opacity: 0,
x: direction * STEP_SLIDE_OFFSET,
}),
center: {
opacity: 1,
x: 0,
transition: { duration: 0.3, ease: EASE_OUT },
},
exit: (direction: StepDirection) => ({
opacity: 0,
x: direction * -STEP_SLIDE_OFFSET,
transition: { duration: 0.18, ease: EASE_OUT },
}),
};
function pad(value: number) {
return String(value).padStart(2, "0");
}
/** Mirrors the shake feel from input.tsx's own error effect. */
function shake(controls: AnimationControls, reduce: boolean) {
if (reduce) return;
controls.start({
x: [0, -6, 6, -4, 4, -2, 0],
transition: { duration: 0.45 },
});
}
function SegmentedProgress({
steps,
activeIndex,
reduce,
}: {
steps: StepFormStep[];
activeIndex: number;
reduce: boolean;
}) {
return (
<div aria-hidden className="flex flex-1 items-center gap-1.5">
{steps.map((step, index) => {
const filled = index <= activeIndex;
return (
<span
key={step.id}
className="h-1 flex-1 overflow-hidden rounded-full bg-border"
>
<motion.span
className="block h-full w-full origin-left rounded-full bg-foreground"
initial={false}
animate={{ scaleX: filled ? 1 : 0 }}
transition={
reduce
? { duration: 0.12 }
: { duration: 0.35, ease: EASE_OUT }
}
/>
</span>
);
})}
</div>
);
}
function SuccessCheck({ reduce }: { reduce: boolean }) {
return (
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-full bg-(--color-success)/12 text-(--color-success)">
<svg viewBox="0 0 48 48" fill="none" className="h-7 w-7" aria-hidden="true">
<motion.circle
cx="24"
cy="24"
r="21"
stroke="currentColor"
strokeWidth={2.5}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5, ease: EASE_OUT }}
/>
<motion.path
d="M14 24.5l6.5 6.5L34 17"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.4,
ease: EASE_OUT,
delay: reduce ? 0 : 0.35,
}}
/>
</svg>
</span>
);
}
/** Shared enter/animate/exit for the two outer views (form ⇄ success). */
function outerViewMotionProps(reduce: boolean) {
return {
initial: reduce
? { opacity: 0 }
: { opacity: 0, y: 8, filter: "blur(4px)" },
animate: reduce
? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }
: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.24, ease: EASE_OUT },
},
exit: reduce
? { opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } }
: {
opacity: 0,
y: -8,
filter: "blur(4px)",
transition: { duration: 0.16, ease: EASE_OUT },
},
} as const;
}
export function StepForm({
steps,
onComplete,
successTitle = "All set",
successMessage,
onRestart,
className,
}: StepFormProps) {
const reduce = useReducedMotion();
const baseId = useId();
const fieldId = `${baseId}-field`;
const errorId = `${baseId}-error`;
const [stepIndex, setStepIndex] = useState(0);
const [direction, setDirection] = useState<StepDirection>(1);
const [values, setValues] = useState<Record<string, string>>({});
const [fieldError, setFieldError] = useState<string | null>(null);
const [status, setStatus] = useState<FormStatus>("active");
const inputRef = useRef<HTMLInputElement>(null);
const choiceShake = useAnimationControls();
const choiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipFocusRef = useRef(true);
const currentStep = steps[stepIndex];
const isLastStep = stepIndex === steps.length - 1;
const currentValue = currentStep ? (values[currentStep.id] ?? "") : "";
const submitting = status === "submitting";
const clearChoiceTimer = () => {
if (choiceTimerRef.current !== null) {
clearTimeout(choiceTimerRef.current);
choiceTimerRef.current = null;
}
};
// Cancel any pending auto-advance if the form unmounts mid-pause.
useEffect(() => {
return () => {
if (choiceTimerRef.current !== null) clearTimeout(choiceTimerRef.current);
};
}, []);
// Focus the field on every step arrival except the very first paint, so
// landing on the form never steals focus but advancing keeps you typing.
// Re-reads `steps[stepIndex]` instead of closing over `currentStep` so a
// run between two consecutive "text" steps still re-triggers on index
// change (kind alone wouldn't differ).
useEffect(() => {
if (skipFocusRef.current) {
skipFocusRef.current = false;
return;
}
const step = steps[stepIndex];
if (status !== "active" || step?.kind !== "text") return;
const timeoutId = setTimeout(
() => inputRef.current?.focus({ preventScroll: true }),
reduce ? 0 : 260,
);
return () => clearTimeout(timeoutId);
}, [stepIndex, status, reduce, steps]);
if (!currentStep) return null;
const goToStep = (nextIndex: number, dir: StepDirection) => {
clearChoiceTimer();
setFieldError(null);
setDirection(dir);
setStepIndex(nextIndex);
};
const goBack = () => {
if (stepIndex === 0) return;
goToStep(stepIndex - 1, -1);
};
const handleComplete = async () => {
setStatus("submitting");
try {
await onComplete?.(values);
setStatus("success");
} catch {
// No error UX is specified for a rejected onComplete — fall back to
// the editable step so the user can retry instead of getting stuck.
setStatus("active");
}
};
const advance = () => {
if (isLastStep) {
void handleComplete();
return;
}
goToStep(stepIndex + 1, 1);
};
const handleValueChange = (next: string) => {
setValues((prev) => ({ ...prev, [currentStep.id]: next }));
setFieldError(null);
};
const handleContinue = () => {
if (submitting) return;
if (currentStep.kind === "text") {
const trimmed = currentValue.trim();
if (currentStep.required && trimmed.length === 0) {
setFieldError("This field is required.");
return;
}
const validationError =
trimmed.length > 0 ? currentStep.validate?.(currentValue) : null;
if (validationError) {
setFieldError(validationError);
return;
}
} else if (currentStep.required && !currentValue) {
setFieldError("Choose an option to continue.");
shake(choiceShake, !!reduce);
return;
}
advance();
};
const handleChoiceSelect = (value: string) => {
if (submitting) return;
handleValueChange(value);
clearChoiceTimer();
choiceTimerRef.current = setTimeout(() => {
choiceTimerRef.current = null;
advance();
}, CHOICE_AUTO_ADVANCE_MS);
};
const titleClassName = cn(
"block text-2xl font-semibold leading-snug text-foreground",
currentStep.eyebrow && "mt-2",
);
return (
<motion.div
layout
transition={SPRING_PANEL}
className={cn(
"w-full max-w-lg overflow-hidden rounded-3xl border border-border bg-card p-6 shadow-sm will-change-transform sm:p-8",
className,
)}
>
<motion.div layout="position">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
{...outerViewMotionProps(!!reduce)}
className="flex flex-col items-center px-2 py-4 text-center"
>
<SuccessCheck reduce={!!reduce} />
<h3 className="mt-5 text-xl font-semibold text-foreground">
{successTitle}
</h3>
{successMessage ? (
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{successMessage}
</p>
) : null}
{onRestart ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-6"
onClick={onRestart}
>
Restart
</Button>
) : null}
</motion.div>
) : (
<motion.div key="form" {...outerViewMotionProps(!!reduce)}>
<div className="flex items-center gap-4">
<SegmentedProgress
steps={steps}
activeIndex={stepIndex}
reduce={!!reduce}
/>
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span className="text-foreground">
{pad(stepIndex + 1)}
</span>
<span className="px-0.5">/</span>
{pad(steps.length)}
</span>
</div>
<div className="mt-8">
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.div
key={currentStep.id}
custom={direction}
variants={stepVariants}
initial={reduce ? { opacity: 0 } : "enter"}
animate={
reduce
? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }
: "center"
}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }
: "exit"
}
>
<div className="flex flex-col gap-2">
{currentStep.eyebrow ? (
<p className="font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
{currentStep.eyebrow}
</p>
) : null}
{currentStep.kind === "text" ? (
<label htmlFor={fieldId} className={titleClassName}>
{currentStep.title}
</label>
) : (
<h3 className={titleClassName}>{currentStep.title}</h3>
)}
{currentStep.hint ? (
<p className="text-sm text-muted-foreground">
{currentStep.hint}
</p>
) : null}
</div>
<div className="mt-6">
{currentStep.kind === "text" ? (
<Input
ref={inputRef}
id={fieldId}
type={currentStep.inputType === "email" ? "email" : "text"}
inputMode={currentStep.inputType === "email" ? "email" : "text"}
placeholder={currentStep.placeholder}
value={currentValue}
onChange={handleValueChange}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleContinue();
}
}}
error={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
disabled={submitting}
/>
) : (
<motion.div
animate={choiceShake}
className="flex flex-col gap-2.5"
>
{currentStep.options.map((option) => {
const selected = currentValue === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
disabled={submitting}
onClick={() => handleChoiceSelect(option.value)}
className={cn(
"flex items-center justify-between gap-4 rounded-2xl border px-4 py-3.5 text-left transition-colors disabled:pointer-events-none disabled:opacity-60",
selected
? "border-foreground bg-primary/5"
: "border-border hover:bg-primary/5",
)}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{option.label}
</span>
{option.description ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
{option.description}
</span>
) : null}
</span>
<span
aria-hidden
className={cn(
"grid h-5 w-5 shrink-0 place-items-center rounded-full border",
selected ? "border-foreground" : "border-border",
)}
>
<motion.span
className="h-2 w-2 rounded-full bg-foreground"
initial={false}
animate={{ scale: selected ? 1 : 0 }}
transition={
reduce ? { duration: 0.12 } : SPRING_SWAP
}
/>
</span>
</button>
);
})}
</motion.div>
)}
</div>
<div className="mt-2 min-h-[1.25rem] px-1">
<AnimatePresence initial={false}>
{fieldError ? (
<motion.p
key="error"
id={errorId}
role="alert"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="text-xs text-destructive"
>
{fieldError}
</motion.p>
) : null}
</AnimatePresence>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="mt-8 flex items-center gap-3">
<AnimatePresence initial={false}>
{stepIndex > 0 ? (
<motion.span
key="back"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={submitting}
>
Back
</Button>
</motion.span>
) : null}
</AnimatePresence>
<Button
type="button"
onClick={handleContinue}
disabled={submitting}
className="ml-auto"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Submitting…
</span>
) : isLastStep ? (
"Complete"
) : (
"Continue"
)}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/input.tsx
"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-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_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>
);
});
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
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;
}
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;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
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, 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>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"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>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} 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;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
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"
>
{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>
);
});
TSXcomponents/motion/magnetic.tsx
"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 参考
stepsStepFormStep[]—onComplete?((values: Record<string, string>) => void | Promise<void>)—successTitle?stringDefault "All set".
All setsuccessMessage?string—onRestart?(() => void)Provide to show a ghost "restart" button on the success screen.
—className?string—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.