Nobody wakes up excited to fill out a form.

I think that's the most honest starting point for this post. Forms are the part of every product that's standing between a person and the thing they actually wanted. They want to use your app. You want their email, their name, their team size, their preferences, their credit card. The form is the toll booth.

And for a long time I treated forms like a solved problem. You have inputs, you have labels, you have a submit button. Done. Ship it. Maybe add some validation messages in red.

Then I watched myself sign up for a bunch of products in one weekend (I was researching onboarding flows, which is a very normal thing to do on a Saturday) and I noticed something. The forms that felt worst weren't the longest ones. They were the ones that felt like they weren't going anywhere. One giant page of fields with no sense of where you were. Or multi-step flows where every "Next" click just snapped to a new screen with no connection to the last one, so you felt like you were being teleported around a government building.

The forms that felt good, even long ones, felt like progress. Every step felt like moving forward. You knew where you were, you knew roughly how much was left, and going back didn't feel like losing anything.

That feeling is what I tried to build into the Multi-Step Form in useLayouts. This post is about how it works, why it's built the way it is, what I'd change for a real registration flow, and all the stuff that went wrong along the way.

I'm Urvish, by the way. I build useLayouts, an open source MIT licensed collection of animated React components. It's built on Next.js, TypeScript, Tailwind CSS, Motion, and it plugs straight into the Shadcn CLI as a registry. The source goes into your project and you own it. Copy, customize, ship.

Why multi-step at all?

Let me argue against myself for a second, because multi-step forms aren't always the answer.

If your form has three fields, it should be one step. Please don't make someone click "Next" to go from their email to their password. That's not progress, that's just extra clicks.

Multi-step makes sense when:

  • The form is long enough that one page would feel overwhelming.
  • The questions naturally group into topics (about you, about your team, about your preferences).
  • Later questions depend on earlier answers.
  • You want people to feel committed after the first step, so they finish the rest.

That last one is a bit of a psychology thing and I want to be careful with it, because it can be used in manipulative ways. But in a good way, it's just this: once someone has typed their project name, they've started. Making that first step small and easy means they're past the hardest part, which is deciding to begin.

The Multi-Step Form demo in useLayouts is a "Create New Project" flow. Three steps:

  1. Create New Project. Project name, due date, description.
  2. Configuration. Team size, priority, tags.
  3. Project Kickoff Mood. An emoji mood picker and a comment box.

It's not literally a registration form, but the structure is the same as most onboarding flows. Basics first, configuration second, something more personal or optional at the end. I'll show you how to turn it into an actual sign up later in this post.

Multi-step form component

Installing it

If you've already got the useLayouts registry in your components.json, you can skip this part:

{ "registries": { "@uselayouts": "https://uselayouts.com/r/{name}.json" } } 

Then:

npx shadcn@latest add @uselayouts/multi-step-form 

Or with your package manager of choice:

pnpm dlx shadcn@latest add @uselayouts/multi-step-form yarn dlx shadcn@latest add @uselayouts/multi-step-form bunx --bun shadcn@latest add @uselayouts/multi-step-form 

This one has more dependencies than most useLayouts components, and that's on purpose. It installs motion, react-hook-form, @hookform/resolvers, zod, date-fns, sonner, lucide-react, and react-use-measure. It also pulls in a bunch of Shadcn UI components as registry dependencies: card, button, input, textarea, select, badge, calendar and popover.

I know that's a long list. I went back and forth on it. The alternative was writing my own form state management, my own date picker, my own select. And that would've been worse in every way. Forms are a place where you really want battle tested pieces. React Hook Form and Zod are what a lot of people already use. Shadcn's form primitives are built on Radix, so the accessibility basics are handled by people who've thought harder about it than I have.

useLayouts isn't trying to replace those. It's trying to be the layer on top that makes the whole thing feel good to move through.

The core idea: one card, sliding content, animated height

If I had to explain the Multi-Step Form in one sentence: it's a single card that stays put, while the content inside slides left and right between steps, and the card smoothly grows or shrinks to fit whatever step you're on.

Each of those three parts matters, so let me go through them.

The card stays put

This is the most important decision and it's kind of invisible. The card, with its header and footer, never leaves the screen. The title changes, the description changes, the step indicator updates, the buttons update. But the frame is stable.

Why does that matter? Because it gives you an anchor. When everything on the screen changes at once, your brain has to re-orient. "Where am I? What is this? Is this still the same thing?" When the frame stays and only the content changes, your brain goes "same form, next part." It's the difference between turning a page in a book and being handed a different book.

A lot of multi-step forms I looked at would transition the entire screen. The whole card would slide out and a new card would slide in. It looks dramatic, but it actually makes the steps feel more disconnected, not less.

The content slides, and it knows which way

Here's the slide logic. It uses Motion's custom prop to pass a direction into the variants:

const variants = { initial: (direction: number) => { return { x: `${110 * direction}%`, opacity: 0 }; }, animate: { x: "0%", opacity: 1 }, exit: (direction: number) => { return { x: `${-110 * direction}%`, opacity: 0 }; }, }; 

When you click Continue, direction is 1. The new step enters from the right (110 percent to the right) and the old step exits to the left. When you click Back, direction is -1, and everything flips. The previous step comes back in from the left, and the current one leaves to the right.

That direction is set right before the step changes:

const nextStep = () => { if (currentStep === 2) { form.handleSubmit(onSubmit)(); return; } if (currentStep < 2) { setDirection(1); setCurrentStep((prev) => prev + 1); } }; const prevStep = () => { if (currentStep > 0) { setDirection(-1); setCurrentStep((prev) => prev - 1); } }; 

This seems like such a small thing but it's honestly the core of the "progress" feeling. Moving forward literally moves forward, in the reading direction. Moving back literally moves back. It builds a spatial map in your head. Step 1 is over there on the left, step 3 is over there on the right, you're in the middle. You can feel where you are.

My first version always slid left, no matter which button you pressed. And going back felt so wrong. Like you clicked "Back" and the form went "okay, forward to the past." It was disorienting in a way I couldn't explain until I watched it in slow motion and realized the direction was lying.

Why 110 percent and not 100? Because at exactly 100 percent, there were moments where the edges of the old and new content would kind of touch and overlap visually, especially with the padding. 110 gives a little gap so they feel like separate pages sliding past each other, not one long strip.

The height animates to fit

This is the trickiest part technically, and it's the one that makes the biggest difference.

Each step has different content, so each step is a different height. Step 1 has three fields including a textarea. Step 2 has a two column row of selects plus a tags input. Step 3 has the mood picker and a big comment box. If you just swap the content, the card height snaps. And when the card snaps, the footer with the buttons jumps up or down. Which means the Continue button, the thing your mouse is sitting on, suddenly moves away from your mouse.

That's awful. Imagine pressing an elevator button and having the button move.

So the content area measures itself and animates its height:

const [ref, bounds] = useMeasure(); <motion.div animate={{ height: bounds.height > 0 ? bounds.height : "auto" }} className="relative overflow-hidden" transition={{ type: "spring", bounce: 0, duration: 0.5 }} > <div ref={ref}> <CardContent className="px-6 py-2 relative"> {/* sliding steps go here */} </CardContent> </div> </motion.div> 

useMeasure from react-use-measure watches the inner div's size. Whenever the content changes, the measured height changes, and the outer motion.div springs to the new height. The overflow-hidden means the sliding content gets clipped cleanly at the card edges while it's moving.

The bounds.height > 0 check is there for the first render. Before measurement happens, the height is zero, and if you animate to zero, the card collapses for a frame and then pops open. Falling back to "auto" until there's a real measurement avoids that.

bounce: 0 on the spring is deliberate. A bouncy height change on a form would feel like the card was made of jelly. You want it to feel solid. It moves, it stops. No wobble.

popLayout is doing a quiet job

The steps are wrapped in AnimatePresence with mode="popLayout":

<AnimatePresence mode="popLayout" initial={false} custom={direction}> <motion.div key={currentStep} variants={variants} initial="initial" animate="animate" exit="exit" className="w-full" custom={direction} > {content} </motion.div> </AnimatePresence> 

popLayout takes the exiting element out of the layout flow while it animates away. So the old step and new step can both be on screen at once, sliding past each other, without stacking on top of each other vertically and making the card temporarily twice as tall.

If you've ever tried to build this with the default mode, you've seen the bug: during the transition, both steps are in the document flow, the card measures the combined height, and it spikes up for a moment before settling. popLayout fixes that. Took me a whole evening to figure out that's what was going on.

The custom={direction} on AnimatePresence itself matters too, and it's easy to miss. The exiting element needs to know the current direction when it exits, but it was rendered with the old direction. Passing custom to AnimatePresence lets it update the exiting child's direction so it leaves the right way. Without that, if you go forward then immediately back, the exiting step can slide out in the wrong direction.

One timing for everything

The whole form is wrapped in MotionConfig:

<MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0, }} > 

So the slide and the height share the same feel by default. A half second spring with no bounce. That consistency matters. If the content slides at one speed and the height adjusts at another, you'll feel them as two separate things happening, and the form starts feeling busy. When they share a timing, they feel like one movement.

Half a second is slower than most of my micro-interactions. That's on purpose too. Moving between steps of a form is a bigger transition than a button press. It's a context change. A slightly longer, smoother motion gives your eyes time to track where the new content is coming from. Faster than about 0.35 and it started feeling like a snap with extra steps. Slower than 0.6 and it felt like the form was making me wait.

The step indicator: dots that stretch

In the top right of the card header there's a step indicator. It's just three little dots, but the active one stretches into a pill:

<div className="flex items-center gap-1.5 pt-1"> {stepTitles.map((_, index) => ( <div key={index} className={cn( "h-2 rounded-full transition-all duration-300", currentStep === index ? "w-8 bg-primary" : "w-2 bg-primary/20" )} /> ))} </div> 

This is plain CSS transitions, not Motion. Not everything needs a spring. A width and color transition is plenty for this.

I like this pattern more than "Step 2 of 3" text, though both are valid. The dots tell you three things at a glance: how many steps there are total, which one you're on, and how many are left. And when you move forward, the pill visually travels to the right, which matches the content sliding. Everything is pointing in the same direction.

I did consider a progress bar instead. The problem with a progress bar on a three step form is it moves in big chunky jumps (33 percent, 66 percent, 100 percent), which feels less like smooth progress and more like a loading bar that keeps stalling. Dots feel more honest about the fact that there are discrete steps.

If your registration flow has more like six or seven steps, dots start getting crowded and I'd probably switch to "Step 4 of 7" text, or a segmented bar. Don't feel stuck with my choice. It's your file.

The steps themselves

Now let me walk through each step, because there are some small details in each one I'm kind of proud of.

Step 1: the easy start

"Create New Project. Start by providing the essential details for your workspace."

Three fields: project name, due date, description. The project name is a plain input. The description is a textarea. The due date uses the Shadcn calendar inside a popover, with the trigger button showing either the formatted date or "Pick a date":

<PopoverTrigger render={ <Button variant={"outline"} className={cn( "w-full justify-start text-left font-normal", !watchedValues["due-date"] && "text-muted-foreground" )} > <CalendarIcon className="mr-2 h-4 w-4" /> {watchedValues["due-date"] ? ( format(watchedValues["due-date"] as Date, "PPP") ) : ( <span>Pick a date</span> )} </Button> } /> 

date-fns's format with "PPP" gives you a nice readable date like "October 7th, 2026" instead of some ISO string. When nothing's picked, the text is muted, so it reads like a placeholder.

The whole point of step 1 is that it's easy. Things you already know the answer to. The project name is in your head. You don't need to think. Starting easy is a kindness.

Step 2: the thinking step

"Configuration. Define team access and project priority settings."

This is where you have to make some decisions. Team size and priority sit side by side in a two column grid, both using Shadcn selects. The options are defined at the top of the file:

const TEAM_SIZE_OPTIONS = [ { label: "Select team size", value: null }, { label: "1-5 Members", value: "1-5" }, { label: "5-10 Members", value: "5-10" }, { label: "10+ Members", value: "10+" }, ]; const PRIORITY_OPTIONS = [ { label: "Select priority", value: null }, { label: "Low", value: "Low" }, { label: "Medium", value: "Medium" }, { label: "High", value: "High" }, { label: "Critical", value: "Critical" }, ]; 

The first option in each has a null value, which acts as the "nothing picked yet" state. That's why the schema allows nullable() on those fields.

Putting the two selects side by side was a deliberate layout call. They're short, they're related, and stacking them vertically made step 2 feel longer than it really was. Pairing short related fields in a row is one of the simplest ways to make a form feel lighter. It's the same number of questions, it just looks like less.

Then there's the tags input, which is my favorite little piece in the whole form. You type a tag, hit Enter, and it becomes a badge above the input:

<Input id="tag" placeholder="e.g. Design, Marketing" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); const val = e.currentTarget.value.trim(); if (val) { const tags = form.getValues("tag") || []; if (!tags.includes(val)) { form.setValue("tag", [...tags, val]); } e.currentTarget.value = ""; } } }} /> 

A couple of things in here that came from mistakes.

e.preventDefault() is critical. Without it, pressing Enter inside a form input submits the form. Which, on step 2 of a three step form, would be a disaster. You'd be trying to add a tag and suddenly the whole form submits half empty. I did that to myself during testing more than once before I added it.

.trim() and the if (val) check stop empty tags or tags that are just spaces. Small, but without them you could spam Enter and fill the list with blank badges.

The !tags.includes(val) check prevents duplicates. If you add "Design" twice, nothing happens the second time. I went back and forth on whether it should show some feedback in that case, like a little shake on the existing badge. I didn't add it in the demo, but if you want to, that's a great example of motion that's actually feedback: "hey, you already have that one, it's right here."

Each badge has a little × button to remove it, which filters that index out of the array. The button is type="button" so it doesn't submit the form either. Forms really want to submit. You have to keep telling them not to.

Step 3: the human step

"Project Kickoff Mood. How confident do you feel about this new project?"

This is the step I had the most fun with, and it's the one that people seem to remember from the demo.

It's a row of five emojis, from anxious to excited:

[ { emoji: "😰", value: "anxious", label: "Anxious" }, { emoji: "😟", value: "worried", label: "Worried" }, { emoji: "😐", value: "neutral", label: "Neutral" }, { emoji: "🙂", value: "good", label: "Good" }, { emoji: "🤩", value: "excited", label: "Excited" }, ] 

Unselected emojis are grayscale. When you hover one, it gets its color back. When you select one, it stays in color with a soft primary tint behind it:

className={cn( "flex-1 p-3 md:p-4 text-2xl md:text-3xl transition-all hover:bg-muted focus:outline-none", watchedValues["mood"] === option.value ? "bg-primary/10 grayscale-0" : "grayscale-[1] hover:grayscale-0" )} 

Underneath, attached to the same bordered box, is a comment textarea with no border of its own, so the emojis and the comment feel like one component. "Add a comment..." And below that, a line of copy: "Your feedback helps us understand the project kickoff vibe."

I want to talk about why this step exists, because it's not really about emojis.

A lot of registration flows end on the most transactional step. Payment, or terms of service, or "confirm your email." So the last impression of the whole flow is a chore. I wanted the demo to show that the last step can be the warmest one. Something that's about the person, not about the product's data needs.

In a real sign up, maybe that's "What are you hoping to build?" or "How did you hear about us?" or literally just a mood picker for how they're feeling about trying a new tool. Whatever it is, ending on something human changes the emotional shape of the whole thing. The flow goes easy, then thinking, then personal. Not easy, thinking, bureaucratic.

The grayscale thing is a nice touch for this too. All five emojis in full color would be loud and kind of chaotic. In grayscale, the row is calm, and picking one "brings it to life." Your choice is literally the colorful thing. That's feedback.

Real talk on accessibility though: emoji buttons have a title attribute with the label, but I'd add proper aria-labels and an aria-pressed state in production so screen readers announce what each one is and which one's selected. And focus:outline-none removes the focus ring, which you should replace with a visible focus style if keyboard users are going to use this. I'll own that. The demo is optimized for showing the interaction, and the production version should be optimized for everyone.

The finish

On the last step, the Continue button turns into Finish with a check icon:

<Button type="button" onClick={nextStep}> {currentStep === stepTitles.length - 1 ? ( <> Finish <Check className="h-4 w-4" /> </> ) : ( <> Continue <ChevronRight className="h-4 w-4" /> </> )} </Button> 

And the Back button is disabled on step 1, since there's nowhere to go back to.

When you hit Finish, nextStep sees you're on the last step and calls form.handleSubmit(onSubmit)(). In the demo, onSubmit just logs the values and pops a Sonner toast showing the JSON:

toast( <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> <code className="text-white">{JSON.stringify(values, null, 2)}</code> </pre> ); 

This is obviously a placeholder. You'll swap it for an API call or a server action. But I kept the JSON toast because it's really useful while you're customizing. You can see exactly what shape your data is in when it comes out the other end. When you add a field, you immediately see whether it made it into the payload.

The thing about validation (please read this part)

Okay, here's the honest bit. In the demo, every field in the Zod schema is optional:

const formSchema = z.object({ "project-name": z.string().optional(), "due-date": z.date().optional(), description: z.string().optional(), "team-size": z.string().nullable().optional(), priority: z.string().nullable().optional(), tag: z.array(z.string()).optional(), mood: z.string().optional(), comment: z.string().optional(), }); 

That's so you can click through the demo without filling anything in and actually see the transitions. If everything was required, you'd hit a wall on step 1 every time you just wanted to see how it moves.

But in your app, you'll want real validation. And with multi-step forms, there's a specific trap here.

If you just make fields required and keep the same nextStep logic, the user can click Continue on step 1 without filling the project name, move to step 2, step 3, and then hit Finish. Only then does handleSubmit run validation. And the error is for a field on step 1, which isn't even on screen anymore. So they click Finish and... nothing happens. Or an error appears somewhere they can't see.

That's one of the worst feelings a form can give you. "I did everything and it didn't work and I don't know why."

The fix is to validate each step before moving forward. React Hook Form has trigger, which validates specific fields:

const stepFields: (keyof FormValues)[][] = [ ["project-name", "due-date", "description"], ["team-size", "priority", "tag"Source:https://dev.to/urvish_16/forms-that-feel-like-progress-building-multi-step-registration-that-doesnt-feel-like-paperwork-21od

Related posts