Multi-step Form
Installation
npx shadcn@latest add https://uibrik.com/r/multi-step-form.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Set up squircle corners
Needs
@squircle/core,@squircle/tailwindcssand a few lines of CSS. See squircle setup.Copy the source into your project
components/uibrik/multi-step-form/MultiStepForm.tsx"use client"; import { cn } from "@/lib/utils"; import NumberFlow from "@number-flow/react"; import { IconChartBar, IconLayoutGrid, IconPencil, IconCornerDownLeft, IconSpeakerphone, IconStack2, IconTool, IconVectorBezier2, } from "@tabler/icons-react"; import { AnimatePresence, animate as animateValue, motion, useAnimate, useMotionValue, useReducedMotion, type Transition, type Variants, } from "motion/react"; import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import useMeasure from "react-use-measure"; import { NOTO_EMOJI_PATHS } from "./notoEmoji"; // A pill that opens into a four-step form, the last of which previews // everything entered and lets any row jump back to its step to edit it. The // card is one surface whose width and height both follow whatever is inside // it — the pill, then each step, each with its own width — measured with // react-use-measure. Steps slide through in the direction of travel: forward // pushes the current step out to the left while the next enters from the // right, back does the mirror image. Finishing folds the card back into a pill // that shows the result. // // Choreography: // - The surface and the slide share one front-loaded curve, so they answer // the click at once and land together; a change of mind mid-flight // (Continue, Continue, Back) retargets from wherever things are. // - The pill opens width first, then height; it closes height first, then // width. Content is centred in the card, so neither moves it sideways and // every flight across stays a straight line. // - Between steps, the card resizes the moment you click; content arrives // into a card that grows and leaves a card that shrinks first. // - Pieces of the incoming step trail in from the side they came from, top // down going forward and bottom up going back. // - The face and the workspace tile are one object each: they fly between // their step, their preview row and the finished pill. // - On touch, the card follows a horizontal swipe, with rubber-banding where // there's nowhere to go. // // Header and footer are pinned to the card's own edges (absolute), not laid // out with the step. They ride the animated box, so they glide as it resizes // instead of snapping to the new step's size and waiting to be uncovered. // The page anchors the card by its top edge for the same reason: a centered // card that grows also drifts, which reads as jitter. // Tabler icons: round joins sit calmly next to the type. const ROLES = [ { id: "design", label: "Design", Icon: IconVectorBezier2 }, { id: "engineering", label: "Engineering", Icon: IconTool }, { id: "product", label: "Product", Icon: IconStack2 }, { id: "marketing", label: "Marketing", Icon: IconSpeakerphone }, { id: "sales", label: "Sales", Icon: IconChartBar }, { id: "other", label: "Other", Icon: IconLayoutGrid }, ] as const; type RoleId = (typeof ROLES)[number]["id"]; // Each step asks for its own width; the card resizes to fit. const STEP_WIDTHS = [340, 380, 480, 400]; const STEP_COUNT = STEP_WIDTHS.length; const PREVIEW = STEP_COUNT - 1; // Motion tokens, pitched by Emil Kowalski's guidance: // - Curves are custom; the built-in CSS ones are too weak to feel intentional. // - Ease-out for anything entering, leaving or answering the user: it moves // the moment you act and settles softly. Never ease-in — it delays the // response, which reads as lag. // - Ease-in-out only for something already on screen travelling elsewhere. // - Durations by size: small UI 150–250ms, a press ~160ms, a surface that // resizes and carries content (drawer / modal class) around 400ms. // - Exits are quicker than entrances; nothing scales up from nothing. // Emil's strong ease-out: enters, exits, responses. const EASE_OUT = [0.23, 1, 0.32, 1] as const; // Emil's ease-in-out: on-screen movement (the role highlight). const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; // The drawer curve (Vaul): the surface and everything riding on it — slide, // resize, flying images, progress. Starts immediately, lands without a crawl. // Motion retargets a tween from wherever it is, so a change of mind // mid-flight stays continuous. const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; const SURFACE = { duration: 0.4, ease: EASE_DRAWER } as const; // Shared elements fly on the same curve as the surface, whatever its duration. const SurfaceContext = createContext<{ duration: number; ease: typeof EASE_DRAWER }>(SURFACE); const ENTER = { duration: 0.22, ease: EASE_OUT } as const; const EXIT = { duration: 0.15, ease: EASE_OUT } as const; const MOVE = { duration: 0.25, ease: EASE_IN_OUT } as const; const PRESS = { duration: 0.16, ease: EASE_OUT } as const; // Reduced motion keeps crossfades — they orient without moving anything. const FADE = { duration: 0.15, ease: EASE_OUT } as const; const INSTANT = { duration: 0 } as const; // Pill ↔ card: the second axis trails the first by just enough to read. // Content settles into focus about here; the caret appears then. const SETTLE_MS = 260; // NumberFlow takes CSS timing; it rolls on the surface curve. const cssCurve = (c: readonly number[]) => `cubic-bezier(${c.join(",")})`; const NUMBER_TIMING = { duration: 400, easing: cssCurve(EASE_DRAWER) }; const NUMBER_OPACITY = { duration: 180, easing: cssCurve(EASE_OUT) }; // A short, decaying "no": each swing smaller than the last, each leg eased. const SHAKE = { x: [0, -6, 6, -3, 3, 0] }; const SHAKE_TRANSITION: Transition = { duration: 0.36, times: [0, 0.15, 0.4, 0.6, 0.8, 1], ease: [...EASE_OUT] }; // The press a pointer would give the button, replayed for Enter. const KEY_PRESS = { scale: [1, 0.97, 1] }; const KEY_PRESS_TRANSITION: Transition = { duration: 0.2, times: [0, 0.35, 1], ease: [...EASE_OUT] }; // Swipe: how far (px) or how fast (px/s) a flick has to be to count, how much // of the finger's travel the card follows, and the rubber band past the ends. const SWIPE_DISTANCE = 72; const SWIPE_VELOCITY = 500; const SWIPE_FOLLOW = 0.5; const rubberBand = (dx: number) => Math.sign(dx) * Math.pow(Math.abs(dx), 0.7); // Pieces of the incoming step read the travel direction from here, so they // trail in from the same side the step itself came from. const DirectionContext = createContext(1); // Motion's useReducedMotion reads the media query on the first client render, // but the server always renders as "motion on" — so a reduced-motion visitor // hits a hydration mismatch. Report "off" until hydrated, then the real value. const noSubscribe = () => () => {}; function usePrefersReducedMotion() { const prefers = useReducedMotion(); const hydrated = useSyncExternalStore(noSubscribe, () => true, () => false); return hydrated && !!prefers; } const slugify = (value: string) => value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); // Focus without letting the browser scroll the clipped card to reveal an input // that is still mid-slide. Focus lands at once, so no keystroke is lost; the // caret stays hidden until the step settles (see `settled`). const focusOnMount = (el: HTMLElement | null) => el?.focus({ preventScroll: true }); export type WorkspaceDetails = { name: string; workspace: string; role: RoleId | null }; export type MultiStepFormProps = { /** Text on the closed pill. */ label?: string; /** Text on the last step's button. */ submitLabel?: string; /** Seconds the card takes to resize between steps. */ duration?: number; /** Delay between width and height as the pill opens, in seconds. */ stagger?: number; /** On touch, swipe between steps. */ swipe?: boolean; /** Show the progress segments and step count. */ progress?: boolean; /** Called with the answers when the last step is confirmed. */ onComplete?: (details: WorkspaceDetails) => void; className?: string; }; /** A pill that opens into a four-step setup form, then folds back into a summary. */ export function MultiStepForm({ label: pillLabel = "Create workspace", submitLabel = "Create workspace", duration = 0.4, stagger: surfaceStagger = 0.07, swipe = true, progress = true, onComplete, className, }: MultiStepFormProps) { const surface = useMemo(() => ({ duration, ease: EASE_DRAWER }), [duration]); const reduceMotion = usePrefersReducedMotion(); const [measureRef, bounds] = useMeasure(); const [scope, animate] = useAnimate<HTMLFormElement>(); const submitRef = useRef<HTMLButtonElement>(null); const dragX = useMotionValue(0); // Only touch and pen swipe; a mouse drag is left to text selection. const swipingRef = useRef(false); const [open, setOpen] = useState(false); // Once the card has been opened, the pill takes focus back when it returns. const [touched, setTouched] = useState(false); const [done, setDone] = useState(false); const [step, setStep] = useState(0); // The step we came from: sets direction, growth and how the progress empties. const [from, setFrom] = useState(0); const [direction, setDirection] = useState(1); // Which way the pill ↔ card morph is running, for axis ordering. const [morph, setMorph] = useState<"open" | "close" | null>(null); // False while a step is still sliding in; the caret waits for it. const [settled, setSettled] = useState(true); const [name, setName] = useState(""); const [workspace, setWorkspace] = useState(""); const [role, setRole] = useState<RoleId | null>(null); // Set while revisiting a step from the preview: submitting (or backing // out) returns straight to the preview instead of walking every step. const [editing, setEditing] = useState(false); // The preview row just edited; it briefly holds a highlight on return. const [landed, setLanded] = useState<number | null>(null); // The preview has no field to type into, so the primary button takes focus // there and Enter still confirms. useEffect(() => { if (open && step === PREVIEW) submitRef.current?.focus({ preventScroll: true }); }, [open, step]); useEffect(() => { if (!open) return; const timer = window.setTimeout(() => setSettled(true), reduceMotion ? 0 : SETTLE_MS); return () => clearTimeout(timer); }, [open, step, reduceMotion]); useEffect(() => { if (!morph) return; const timer = window.setTimeout(() => setMorph(null), 550); return () => clearTimeout(timer); }, [morph]); const go = (next: number) => { setFrom(step); setDirection(next > step ? 1 : -1); setStep(next); setSettled(false); setLanded(null); }; const openCard = () => { if (done) { setName(""); setWorkspace(""); setRole(null); setDone(false); } if (done || !touched) { setDirection(1); setFrom(0); setStep(0); } setEditing(false); setTouched(true); setSettled(false); setMorph("open"); setOpen(true); }; const collapse = () => { setMorph("close"); setLanded(null); setOpen(false); dragX.set(0); }; const shake = (el: Element | null) => { if (el && !reduceMotion) animate(el, SHAKE, SHAKE_TRANSITION); }; const pressSubmit = () => { if (submitRef.current && !reduceMotion) animate(submitRef.current, KEY_PRESS, KEY_PRESS_TRANSITION); }; const edit = (target: number) => { setEditing(true); go(target); }; const backToPreview = () => { const edited = step; setEditing(false); go(PREVIEW); setLanded(edited); }; const canContinue = !!(open && [name.trim(), workspace.trim(), role, true][step]); const isLast = step === STEP_COUNT - 1; const canSwipeForward = canContinue && (editing || step < PREVIEW); const canSwipeBack = open && (editing || step > 0); const advance = () => { if (!canContinue) return shake(scope.current); if (editing) return backToPreview(); if (!isLast) return go(step + 1); setDone(true); onComplete?.({ name, workspace, role }); collapse(); }; const retreat = () => { if (editing) return backToPreview(); if (step === 0) return collapse(); go(step - 1); }; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); if (open) advance(); }; // A card that grows lets content arrive into the space it opens; one that // shrinks lets content leave first. Only the content waits (delayChildren): // the resize itself always starts on the click. Width is known per step, so it stands in for size here — the // step change and the resize are one render apart, too early to wait on a // measurement. const growing = STEP_WIDTHS[step] >= STEP_WIDTHS[from]; const sizeTransition: Transition = reduceMotion ? INSTANT : morph === "open" ? { width: surface, height: { ...surface, delay: surfaceStagger } } : morph === "close" ? { height: surface, width: { ...surface, delay: surfaceStagger } } : surface; // The step container only moves; its pieces handle their own fades, so a // shared image inside never dips in opacity while it flies. const slide: Variants = { enter: (d: number) => ({ x: reduceMotion ? 0 : `${d * 100}%` }), center: (d: number) => ({ x: 0, transition: { x: reduceMotion ? INSTANT : surface, staggerChildren: reduceMotion ? 0 : 0.035, delayChildren: reduceMotion ? 0 : growing ? 0.06 : 0.03, // Top down going forward, bottom up going back. staggerDirection: d, }, }), exit: (d: number) => ({ x: reduceMotion ? 0 : `${d * -100}%`, transition: { x: reduceMotion ? INSTANT : surface } }), }; // Content waits a beat for the surface to open up, but leaves immediately. // Blur is kept to this one crossfade: pill ↔ card. // Opening, height only starts after width, so content holds back longer — // until there's room for it — rather than piling up in a half-open card. const fadeIn = reduceMotion ? FADE : { ...ENTER, delay: morph === "open" ? 0.18 : 0.06 }; const fadeOut = reduceMotion ? FADE : EXIT; const blur = (px: number) => (reduceMotion ? "blur(0px)" : `blur(${px}px)`); const pillText = { initial: { opacity: 0, filter: blur(4) }, animate: { opacity: 1, filter: "blur(0px)", transition: fadeIn }, }; const pillWords = { initial: { opacity: 0, filter: blur(2) }, animate: { opacity: 1, filter: "blur(0px)", transition: reduceMotion ? FADE : { ...ENTER, delay: 0.16 }, }, }; const chrome = { ...pillText, exit: { opacity: 0, filter: blur(4), transition: fadeOut }, }; // Multi-step jumps (preview → name) fill or empty the progress one segment // at a time, in the direction of travel. const segmentDelay = (i: number) => { if (reduceMotion) return 0; const lo = Math.min(from, step); const hi = Math.max(from, step); if (i <= lo || i > hi) return 0; return (step > from ? i - lo - 1 : hi - i) * 0.05; }; // Swipe: follow the finger where there's a step to go to, resist where there // isn't, and commit on distance or a quick flick. const onPan = (_: PointerEvent, info: { offset: { x: number } }) => { if (!swipingRef.current) return; const dx = info.offset.x; const allowed = dx < 0 ? canSwipeForward : canSwipeBack; dragX.set(allowed ? dx * SWIPE_FOLLOW : rubberBand(dx)); }; const onPanEnd = (_: PointerEvent, info: { offset: { x: number }; velocity: { x: number } }) => { if (!swipingRef.current) return; swipingRef.current = false; const dx = info.offset.x; const vx = info.velocity.x; const flung = Math.abs(dx) > SWIPE_DISTANCE || (Math.abs(vx) > SWIPE_VELOCITY && Math.sign(vx) === Math.sign(dx)); if (flung && dx < 0 && canSwipeForward) advance(); else if (flung && dx > 0 && canSwipeBack) retreat(); animateValue(dragX, 0, reduceMotion ? INSTANT : surface); }; const slug = slugify(workspace); const [firstName, ...otherNames] = name.trim().split(/\s+/); const surname = otherNames.join(" "); const selectedRole = ROLES.find((r) => r.id === role); const label = editing ? "Save" : isLast ? submitLabel : "Continue"; const caret = settled ? "" : "caret-transparent"; return ( <SurfaceContext.Provider value={surface}> <motion.form ref={scope} onSubmit={onSubmit} onKeyDown={(e) => { if (!open) return; if (e.key === "Escape") { e.preventDefault(); collapse(); } // Enter from a field gets the same press a click would. if (e.key === "Enter" && canContinue && e.target instanceof HTMLInputElement) pressSubmit(); }} noValidate className={cn( "relative flex max-w-[calc(100vw-32px)] items-start justify-center overflow-hidden rounded-3xl [clip-path:inset(0_round_1.5rem)] bg-neutral-100 text-zinc-900 dark:bg-neutral-800 dark:text-zinc-100", className, )} initial={false} animate={{ width: bounds.width || "auto", height: bounds.height || "auto" }} transition={sizeTransition} > {/* Centred in the card, which animates its size around it: the card is centred on the page too, so the content stays put while the card's edges move. Pinned to the left edge instead, it would be dragged sideways by every resize, bending each shared flight into a curve. */} <div ref={measureRef} className="w-max shrink-0"> <AnimatePresence mode="popLayout" initial={false}> {open ? ( <motion.div key="form" className="pt-11 pb-16" {...chrome}> <motion.div className="relative touch-pan-y" style={{ x: dragX }} onPanStart={(e) => { swipingRef.current = e.pointerType !== "mouse"; }} onPan={swipe ? onPan : undefined} onPanEnd={swipe ? onPanEnd : undefined} > <DirectionContext.Provider value={direction}> <AnimatePresence mode="popLayout" initial={false} custom={direction}> <motion.div key={step} custom={direction} variants={slide} initial="enter" animate="center" exit="exit" style={{ width: `min(${STEP_WIDTHS[step]}px, calc(100vw - 32px))` }} className="flex flex-col gap-5 p-6" > {step === 0 && ( <> <Piece solid> <Shared id="me"> <Avatar seed={name} className="size-14" /> </Shared> </Piece> <Heading title="What's your name?" description="This is how you'll appear to your team." /> <Piece> <Field label="Full name" htmlFor="name"> <input id="name" ref={focusOnMount} value={name} onChange={(e) => setName(e.target.value)} placeholder="Jane Appleseed" autoComplete="name" className={cn(inputClass, caret)} /> </Field> </Piece> </> )} {step === 1 && ( <> <Piece solid> <Shared id="workspace"> <WorkspaceIcon seed={workspace} className="size-14" /> </Shared> </Piece> <Heading title="Name your workspace" description="Usually your company or team name." /> <Piece className="flex flex-col gap-2.5"> <Field label="Workspace" htmlFor="workspace"> <input id="workspace" ref={focusOnMount} value={workspace} onChange={(e) => setWorkspace(e.target.value)} placeholder="Acme Inc." autoComplete="organization" className={cn(inputClass, caret)} /> </Field> <p className="flex overflow-hidden whitespace-nowrap text-sm font-normal text-zinc-400 dark:text-zinc-500"> acme.app/ <span className="relative flex text-zinc-700 dark:text-zinc-200"> <AnimatePresence mode="popLayout" initial={false}> {(slug || "your-team").split("").map((char, i) => ( <motion.span key={`${i}-${char}-${!slug}`} className={cn("inline-block", !slug && "text-zinc-400 dark:text-zinc-500")} initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }} animate={{ opacity: 1, y: 0, transition: reduceMotion ? FADE : ENTER }} exit={{ opacity: 0, y: reduceMotion ? 0 : -6, transition: reduceMotion ? FADE : EXIT }} > {char} </motion.span> ))} </AnimatePresence> </span> </p> </Piece> </> )} {step === 2 && ( <> <Heading title="What do you do?" description="We'll tailor your setup to fit." /> <Piece> <fieldset className="grid grid-cols-3 gap-1.5"> <legend className="sr-only">Role</legend> {ROLES.map(({ id, label: roleLabel, Icon }, i) => { const selected = role === id; return ( <label key={id} className="group relative cursor-pointer select-none"> <input type="radio" name="role" value={id} ref={i === 0 && !role ? focusOnMount : undefined} checked={selected} onChange={() => setRole(id)} className="peer sr-only" /> <span className="absolute inset-0 bg-white/40 squircle-mask squircle-xl transition-colors duration-200 ease-out-strong group-hover:bg-white/80 dark:bg-neutral-700/30 dark:group-hover:bg-neutral-700/60" /> {selected && ( <motion.span layoutId="role-highlight" className="absolute inset-0 bg-white squircle-mask squircle-xl dark:bg-neutral-700" transition={reduceMotion ? INSTANT : MOVE} /> )} <span className="pointer-events-none absolute inset-0 rounded-xl ring-zinc-400 peer-focus-visible:ring-2" /> <span className={cn( "relative flex flex-col gap-3 p-3.5 text-base font-medium tracking-tight transition-colors duration-200 ease-out-strong", selected ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-500 dark:text-zinc-400", )} > <motion.span className="block origin-bottom-left" animate={ reduceMotion ? undefined : { scale: selected ? 1.12 : 1, rotate: selected ? -6 : 0 } } transition={ENTER} > <Icon stroke={1.75} className="size-5" /> </motion.span> {roleLabel} </span> </label> ); })} </fieldset> </Piece> </> )} {step === 3 && ( <> <Heading title="Preview details" description="Make sure everything looks right." /> <Piece solid className="flex flex-col bg-white p-1.5 squircle-mask squircle-xl dark:bg-neutral-700/50"> <PreviewRow label="You" name={name.trim()} value={ // The first name flies into the pill; the // surname stays and fades out with the card. <> <SharedText id="first-name">{firstName}</SharedText> {surname && <span className="whitespace-pre"> {surname}</span>} </> } landing={landed === 0} onEdit={() => edit(0)} media={ <Shared id="me" ring> <Avatar seed={name} className="size-10" /> </Shared> } /> <PreviewRow label={`acme.app/${slug}`} name={workspace.trim()} value={<SharedText id="workspace-name">{workspace.trim()}</SharedText>} landing={landed === 1} onEdit={() => edit(1)} media={ <Shared id="workspace"> <WorkspaceIcon seed={workspace} className="size-10" /> </Shared> } /> <PreviewRow label="Role" name={selectedRole?.label ?? ""} value={selectedRole?.label} landing={landed === 2} onEdit={() => edit(2)} media={ <span className="flex size-10 items-center justify-center bg-neutral-100 squircle-mask squircle-lg text-zinc-600 dark:bg-neutral-800 dark:text-zinc-300"> {selectedRole && ( <selectedRole.Icon stroke={1.75} className="size-5" /> )} </span> } /> </Piece> </> )} </motion.div> </AnimatePresence> </DirectionContext.Provider> </motion.div> </motion.div> ) : ( <motion.button key="pill" type="button" ref={touched ? focusOnMount : undefined} onClick={openCard} whileTap={reduceMotion ? undefined : { scale: 0.97, transition: PRESS }} className="flex cursor-pointer items-center gap-2.5 rounded-3xl py-2 pr-5 pl-2 text-base font-medium tracking-tight whitespace-nowrap outline-none transition-colors duration-200 ease-out-strong hover:bg-neutral-200/70 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-400 dark:hover:bg-neutral-700/70" // No fade on the pill itself: the shared tile and face must land // fully visible. Its text fades in on its own (pillText). exit={{ opacity: 0, filter: blur(4), transition: fadeOut }} > {done ? ( <> {/* The tile and face fly here from their preview rows; the tile rounds into a circle on the way to match the face. */} <span className="flex -space-x-2"> <Shared id="workspace" round className="ring-2 ring-neutral-100 dark:ring-neutral-800"> <WorkspaceIcon seed={workspace} className="size-8" /> </Shared> <Shared id="me" round className="ring-2 ring-neutral-100 dark:ring-neutral-800"> <Avatar seed={name} className="size-8" /> </Shared> </span> {/* The two names fly in from their preview rows; the words around them fade in a beat later, assembling the sentence around what arrived. */} <span className="flex items-baseline whitespace-pre"> <motion.span {...pillWords}>Welcome to </motion.span> <SharedText id="workspace-name">{workspace.trim()}</SharedText> <motion.span {...pillWords}>, </motion.span> <SharedText id="first-name">{firstName}</SharedText> </span> </> ) : ( <> <motion.span {...pillText} className="flex size-8 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" > <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className="size-4"> <path d="M8 3v10M3 8h10" /> </svg> </motion.span> <motion.span {...pillText}>{touched ? "Continue setup" : pillLabel}</motion.span> </> )} </motion.button> )} </AnimatePresence> </div> <AnimatePresence initial={false}> {open && ( <motion.div key="header" {...chrome} className="absolute inset-x-0 top-0 flex items-center justify-between px-6 pt-6" > <div className={cn("flex gap-1.5", !progress && "invisible")} aria-hidden="true"> {Array.from({ length: STEP_COUNT }, (_, i) => ( <span key={i} className="h-1 w-6 overflow-hidden rounded-full bg-zinc-300 dark:bg-neutral-600"> <motion.span className="block h-full origin-left bg-zinc-900 dark:bg-zinc-100" initial={false} animate={{ scaleX: i <= step ? 1 : 0 }} transition={reduceMotion ? INSTANT : { ...surface, delay: segmentDelay(i) }} /> </span> ))} </div> <p className={cn("flex items-center text-sm font-normal text-zinc-400 dark:text-zinc-500", !progress && "invisible")} aria-live="polite"> Step <NumberFlow value={step + 1} className="tabular-nums" animated={!reduceMotion} transformTiming={NUMBER_TIMING} spinTiming={NUMBER_TIMING} opacityTiming={NUMBER_OPACITY} /> of {STEP_COUNT} </p> </motion.div> )} </AnimatePresence> <AnimatePresence initial={false}> {open && ( <motion.div key="footer" {...chrome} className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 px-6 pb-6" > <button type="button" onClick={retreat} className="cursor-pointer rounded-md px-1 py-2 text-base font-medium tracking-tight text-zinc-500 outline-none transition-colors duration-200 ease-out-strong hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:text-zinc-100" > {step === 0 && !editing ? "Cancel" : "Back"} </button> <div className="flex items-center gap-3"> <span aria-hidden="true" className={cn( "flex items-center gap-1.5 text-sm leading-none font-normal text-zinc-400 transition-opacity duration-200 ease-out-strong dark:text-zinc-500", canContinue ? "opacity-100" : "opacity-0", )} > <IconCornerDownLeft stroke={1.75} className="size-4 shrink-0" /> Enter </span> <motion.button ref={submitRef} type="submit" aria-disabled={!canContinue} whileTap={canContinue && !reduceMotion ? { scale: 0.97 } : undefined} transition={PRESS} className={cn( "relative min-w-32 cursor-pointer overflow-hidden bg-zinc-900 px-4 py-2 squircle-mask squircle-xl text-base font-medium tracking-tight text-white outline-none transition-opacity duration-200 ease-out-strong focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-100 dark:bg-zinc-100 dark:text-zinc-900 dark:focus-visible:ring-offset-neutral-800", !canContinue && "opacity-40", )} > <AnimatePresence mode="popLayout" initial={false} custom={direction}> <motion.span key={label} className="block" initial={{ opacity: 0, y: reduceMotion ? 0 : 10 * direction }} animate={{ opacity: 1, y: 0, transition: reduceMotion ? FADE : ENTER }} exit={{ opacity: 0, y: reduceMotion ? 0 : -10 * direction, transition: reduceMotion ? FADE : EXIT }} > {label} </motion.span> </AnimatePresence> </motion.button> </div> </motion.div> )} </AnimatePresence> </motion.form> </SurfaceContext.Provider> ); } const inputClass = "w-full bg-white px-3.5 py-2.5 squircle-mask squircle-xl text-base font-normal text-zinc-900 outline-none placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:cursor-not-allowed dark:bg-neutral-700 dark:text-zinc-100 dark:placeholder:text-zinc-500"; // One staggered block of a step. It fades in (and out, quicker than the step // travels, so two half-faded steps never overlap for long). A `solid` piece // holds a shared image and neither fades nor trails: the image flies between // steps at full opacity, carried only by the step's slide. Any extra nudge on // its own curve would add to the flight on one axis and bend its path. function Piece({ solid, className, children }: { solid?: boolean; className?: string; children: React.ReactNode }) { const direction = useContext(DirectionContext); const reduceMotion = usePrefersReducedMotion(); const variants: Variants = { enter: (d: number) => ({ x: reduceMotion || solid ? 0 : 20 * d, opacity: solid ? 1 : 0 }), center: { x: 0, opacity: 1, transition: reduceMotion ? FADE : { duration: 0.32, ease: EASE_OUT } }, exit: solid ? {} : { opacity: 0, transition: reduceMotion ? FADE : EXIT }, }; return ( <motion.div custom={direction} variants={variants} className={className}> {children} </motion.div> ); } function Heading({ title, description }: { title: string; description: string }) { return ( <Piece className="flex flex-col gap-1"> <h2 className="text-xl font-semibold tracking-tight">{title}</h2> <p className="text-base font-normal text-zinc-500 dark:text-zinc-400">{description}</p> </Piece> ); } // A shared element: the same id rendered in its step, its preview row and the // finished pill, so Motion flies it from one box to the next on the surface // curve instead of fading one copy out and another in. It clips its content // to its own radius, and the radius lives in style: Motion scale-corrects it // mid-flight and interpolates it when the two ends differ — which is how the // workspace tile (28%) rounds into a circle (50%) as it lands in the pill. // // No layoutDependency here: gating re-measures on a key also blocked the // handoff out of the exiting form into the pill, so the images snapped. It // isn't needed — Motion snapshots before and measures after each commit in the // same frame, so a re-render mid-slide sees no change and flies nothing. function Shared({ id, round, ring, className, children, }: { id: string; round?: boolean; ring?: boolean; className?: string; children: React.ReactNode; }) { const reduceMotion = usePrefersReducedMotion(); const surface = useContext(SurfaceContext); return ( <motion.span layoutId={`shared-${id}`} className={cn("relative block w-fit shrink-0 overflow-hidden", className)} style={{ borderRadius: round ? "50%" : "28%" }} transition={reduceMotion ? INSTANT : surface} > {children} {/* A hairline drawn inside, since overflow-hidden would clip one outside. */} {ring && ( <span className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.1)]" /> )} </motion.span> ); } // Text that flies between the preview and the pill. Same size and weight at // both ends, so it travels without any scaling; inline-block because an // inline box can't take the transform the flight is made of. function SharedText({ id, children }: { id: string; children: React.ReactNode }) { const reduceMotion = usePrefersReducedMotion(); const surface = useContext(SurfaceContext); return ( <motion.span layoutId={`shared-${id}`} className="inline-block" transition={reduceMotion ? INSTANT : surface}> {children} </motion.span> ); } // One editable line of the preview. The whole row is the edit target; the // pen fades in on hover or focus to say so without cluttering the resting state. // Returning from an edit, the row holds its hover tint for a moment and lets // it go, so you can see where the change landed. function PreviewRow({ label, name, value, media, landing, onEdit, }: { label: string; name: string; value: React.ReactNode; media: React.ReactNode; landing: boolean; onEdit: () => void; }) { return ( <button type="button" onClick={onEdit} aria-label={`Edit ${name}`} className="group relative flex w-full cursor-pointer items-center gap-3 p-2 text-left squircle-mask squircle-lg outline-none transition-colors duration-200 ease-out-strong hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:hover:bg-neutral-700" > {landing && ( <motion.span aria-hidden="true" className="absolute inset-0 bg-neutral-100 squircle-mask squircle-lg dark:bg-neutral-700" initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ delay: 0.25, duration: 0.6, ease: EASE_OUT }} /> )} <span className="relative">{media}</span> <span className="relative flex min-w-0 flex-1 flex-col"> <span className="truncate whitespace-nowrap text-base font-medium tracking-tight">{value}</span> <span className="truncate text-sm font-normal text-zinc-400 dark:text-zinc-500">{label}</span> </span> <span className="relative flex size-8 shrink-0 translate-x-1 items-center justify-center text-zinc-400 opacity-0 transition-[opacity,translate] duration-200 ease-out-strong group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100 dark:text-zinc-500"> <IconPencil stroke={1.75} className="size-4" /> </span> </button> ); } function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: React.ReactNode }) { return ( <div className="flex flex-col gap-1.5"> <label htmlFor={htmlFor} className="text-sm font-medium text-zinc-500 dark:text-zinc-400"> {label} </label> {children} </div> ); } // People get DiceBear's line-art "notionists" faces. An empty name falls back // to a default face rather than a blank circle. const DEFAULT_PERSON = "Felix"; const portraitUrl = (seed: string) => `https://api.dicebear.com/9.x/notionists-neutral/svg?seed=${encodeURIComponent(seed)}&backgroundColor=transparent`; // Images the browser already has, so a remounted avatar (the pill, a preview // row) shows on its first frame instead of fading in. const loadedImages = new Set<string>(); // The URL that is ready to show. While typing, a new image is only requested // once the keystrokes pause, and only swapped in once it has fully loaded, so // the avatar crossfades between faces and never blinks empty. function usePortrait(seed: string) { const url = portraitUrl(seed.trim() || DEFAULT_PERSON); const [shown, setShown] = useState(() => (loadedImages.has(url) ? url : "")); useEffect(() => { let cancelled = false; const timer = window.setTimeout( () => { const img = new Image(); img.onload = () => { loadedImages.add(url); if (!cancelled) setShown(url); }; img.src = url; }, loadedImages.has(url) ? 0 : 180, ); return () => { cancelled = true; clearTimeout(timer); }; }, [url]); return shown; } function Avatar({ seed, className }: { seed: string; className?: string }) { const reduceMotion = usePrefersReducedMotion(); const url = usePortrait(seed); return ( // Clipped with clip-path, not overflow + border-radius: Safari drops that // rounded clip for children that are animating (their own crossfade, or // an ancestor scaling as it flies into the pill) and the face flashes // square corners. clip-path holds its shape the whole way. // Its own corners match the workspace tile (28%); where the face should be // a circle, the Shared around it rounds it further, the same way the tile // becomes a circle in the pill. <span className={cn( "relative block shrink-0 rounded-[28%] bg-white [clip-path:inset(0_round_28%)] dark:bg-neutral-200", className, )} > <AnimatePresence mode="popLayout" initial={false}> {url && ( <motion.img key={url} src={url} alt="" draggable={false} className={cn("absolute inset-0 size-full", !seed.trim() && "opacity-50")} initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.94 }} animate={{ opacity: 1, scale: 1, transition: reduceMotion ? FADE : ENTER }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 1.04, transition: reduceMotion ? FADE : EXIT }} /> )} </AnimatePresence> </span> ); } // Workspaces get a solid Google Noto Emoji glyph — from the monochrome font, // drawn as an SVG path so it centres on its own shape — in white on a grey // tile, strictly black and white. Both are picked from the name, so a // workspace always keeps its look. const WORKSPACE_GREYS = ["#18181b", "#27272a", "#3f3f46", "#52525b", "#71717a"]; const DEFAULT_WORKSPACE = "workspace"; const hashOf = (value: string) => { let hash = 0; for (const char of value.toLowerCase()) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash; }; function WorkspaceIcon({ seed, className }: { seed: string; className?: string }) { const reduceMotion = usePrefersReducedMotion(); const value = seed.trim() || DEFAULT_WORKSPACE; // Settle on a glyph once typing pauses, so it doesn't flicker per keystroke. const [settled, setSettled] = useState(value); useEffect(() => { const timer = window.setTimeout(() => setSettled(value), 150); return () => clearTimeout(timer); }, [value]); const hash = hashOf(settled); const index = hash % NOTO_EMOJI_PATHS.length; const path = NOTO_EMOJI_PATHS[index]; const grey = WORKSPACE_GREYS[Math.floor(hash / NOTO_EMOJI_PATHS.length) % WORKSPACE_GREYS.length]; return ( <motion.span // Real, proportional corners clipped by clip-path (see Avatar) — the // squircle utility paints its own background, which an animated // background-color would square off. className={cn( "relative flex shrink-0 items-center justify-center rounded-[28%] text-white [clip-path:inset(0_round_28%)]", className, )} initial={false} animate={{ backgroundColor: grey }} transition={reduceMotion ? FADE : ENTER} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={index} className="flex size-1/2 items-center justify-center" initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.85, rotate: reduceMotion ? 0 : -15 }} animate={{ opacity: 1, scale: 1, rotate: 0, transition: reduceMotion ? FADE : ENTER }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.85, rotate: reduceMotion ? 0 : 15, transition: reduceMotion ? FADE : EXIT, }} > <svg viewBox="0 0 24 24" aria-hidden="true" className="size-full"> <path d={path} fill="currentColor" /> </svg> </motion.span> </AnimatePresence> </motion.span> ); }
Adds the files to
components/uibrik/multi-step-form and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/multi-step-form.Props
MultiStepForm- label
- Type string
- "Create workspace"
- Text on the closed pill.
- submitLabel
- Type string
- "Create workspace"
- Text on the last step's button.
- duration
- Type number
- 0.4
- How long the card takes to resize between steps.
- stagger
- Type number
- 0.07
- Delay between width and height as the pill opens.
- swipe
- Type boolean
- true
- On touch, swipe between steps.
- progress
- Type boolean
- true
- Show the progress segments and step count.
- onComplete
- Type (details: WorkspaceDetails) => void
- –
- Called with name, workspace and role when the last step is confirmed.
- className
- Type string
- –
- Classes for the card.