Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 31 min read

Forms That Feel Like Progress: Building Multi-Step Registration That Doesn't Feel Like Paperwork

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

Forms That Feel Like Progress: Building Multi-Step Registration That Doesn't Feel Like Paperwork

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"],
  ["mood", "comment"],
];

const nextStep = async () => {
  const valid = await form.trigger(stepFields[currentStep]);
  if (!valid) return;

  if (currentStep === stepFields.length - 1) {
    form.handleSubmit(onSubmit)();
    return;
  }
  setDirection(1);
  setCurrentStep((prev) => prev + 1);
};

Now Continue checks only the current step's fields. If something's wrong, you stay on that step and the FieldError components (which are already wired up under every field in the demo) show the messages right where you're looking.

Back should not validate. If someone wants to go back and fix something, let them. Blocking "Back" because the current step is incomplete is a special kind of frustrating.

And then make the schema real:

const formSchema = z.object({
  "project-name": z.string().min(1, "Give your project a name"),
  "due-date": z.date().optional(),
  description: z.string().optional(),
  "team-size": z
    .string()
    .nullable()
    .refine((v) => v !== null, "Pick a team size"),
  // ...
});

Notice the error message. "Give your project a name" instead of "Required" or "String must contain at least 1 character(s)". Error copy is part of the interaction. It's feedback. The default Zod messages are written for developers. Rewrite them for humans. It takes ten minutes and it changes how the whole form feels when something goes wrong.

Turning it into a real registration flow

Let me walk through how I'd actually adapt this for a sign up, because "Create New Project" is close but not quite.

Say you're building a SaaS and the sign up needs: email, password, name, workspace name, team size, role, and how they heard about you.

That's seven fields. On one page, it's a wall. As three steps, it can feel like a conversation.

Step 1: "Let's get you in." Email and password. That's it. This is the minimum to create an account. If someone bails after this, you at least have an account you can follow up on (with their permission, obviously).

Step 2: "Tell us about your workspace." Name, workspace name, team size. Workspace name and team size could be side by side, same as the selects in the demo.

Step 3: "One last thing." Role and how they heard about you. Both optional. Maybe the role is an emoji style picker like the mood step, or a set of chips. And a "Skip" option alongside Finish.

Here's the shape of the changes in the actual file:

// Change Here
const stepTitles = [
  {
    title: "Let's get you in",
    description: "Just your email and a password to start.",
  },
  {
    title: "Your workspace",
    description: "This is where your team will work together.",
  },
  {
    title: "One last thing",
    description: "Totally optional, but it helps us help you.",
  },
];

That // Change Here comment sits right above stepTitles in the real component, same as other useLayouts components, so you know where to start editing.

Then you'd update the switch (currentStep) cases with your fields, update the schema, add the per step validation from above, and replace the toast with your actual sign up call.

The motion stuff, the sliding, the height, the direction, the dots, all keeps working without you touching it. That's the part I want useLayouts to handle so you don't have to. You bring the fields and the copy. The flow already feels right.

Also, if you have more than three steps, a few things in the demo are hardcoded to three, like currentStep === 2 and currentStep < 2 in nextStep. Swap those for stepTitles.length - 1 and you're good for any number of steps. I'd honestly make that change even if you stay at three, just so it's not a trap later.

Failure modes I've hit (or seen coming)

Here's the list of stuff that can go wrong with multi-step forms in general, and how I think about each one with this component.

Focus gets lost between steps

When you click Continue, the step content changes, but keyboard focus stays on the Continue button. That's actually fine for mouse users. For keyboard and screen reader users, though, the new step's content is now above them, and they have to tab backwards to reach it, or they might not even know the content changed.

What I'd do in production: after the step changes, move focus to the first input in the new step, or to the step's title. You can do this with a ref and an effect that runs when currentStep changes. Be a bit careful with timing, because the new content is animating in. Focusing an element that's mid-slide is fine functionally, but some browsers will scroll to it, which can look weird. Using focus({ preventScroll: true }) helps.

Also, consider announcing the step change for screen readers. An aria-live="polite" region with the step title is a simple way.

The Enter key

I talked about this with tags, but it applies generally. In a multi-step form, pressing Enter in a text input will try to submit the form. Which in this component isn't wired to a native form submit by default (the buttons are type="button" and Finish calls handleSubmit directly), so it's less of a risk. But if you wrap it in a <form> element yourself, which you might want for things like password managers, remember that Enter will submit. You might want Enter to mean "Continue" on intermediate steps instead. That's a nice touch, honestly. It lets people fly through the form without touching the mouse.

Browser back button

This one's sneaky. The steps are local component state, not URL state. So if someone is on step 3 and presses the browser back button, they don't go to step 2. They leave the page entirely. And lose everything.

For a short form that's arguably fine. For a long registration, that's a real way to lose people. Options: sync the step to a query param (?step=2) so the browser back button moves between steps, or warn before unload if the form is dirty. Neither is in the demo because both depend heavily on your routing setup. But think about it.

Data on unmounted steps

When you move from step 1 to step 2, step 1's inputs unmount. Will their values survive? With React Hook Form's default settings, yes, because shouldUnregister defaults to false, so values stay in the form state even after the field unmounts. That's why going Back in the demo shows what you typed earlier.

But if you ever flip that setting, or move some field into local useState instead of the form, you'll lose data when switching steps. I've done it. I put a field in local state "just to test something quickly," forgot about it, and then couldn't understand why it kept resetting. If going back loses data, the whole "progress" feeling collapses. People will not fill something out twice.

The tags input is a good example to look at here. It doesn't use register because it's not a simple value. It reads and writes the array with getValues and setValue. That keeps it in form state even though it's a custom control.

The height animation and dynamic content

The height animation works great when step content is static. But what if content changes height within a step? Like when a validation error appears under a field, or when someone adds five tags and the badge row wraps to two lines?

The good news is useMeasure keeps watching, so the card grows smoothly when errors appear or tags wrap. That's actually a nice side effect. Error messages sliding the card open is gentler than error messages shoving everything down.

The thing to watch for is popovers. The calendar is in a popover that renders outside the card, so it doesn't affect the height. If you add your own dropdown or expanding thing inline inside a step, it will make the card animate, which may or may not be what you want.

Mobile keyboards

On a phone, when the on screen keyboard opens, the viewport shrinks. If your multi-step form is vertically centered, the Continue button might end up under the keyboard. The card being a fixed frame with the footer at the bottom helps a bit, but test it on a real phone. Not just a desktop browser resized narrow. A real phone, with a real keyboard, with your real thumb.

Reduced motion

With MotionConfig, you can add reducedMotion="user":

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

And Motion will skip the transform based slide for people who've asked for reduced motion, while still letting opacity changes happen. The steps will still change, the card will still adjust. They just won't fly in from the side. The step dots and titles still tell you where you are, so the form stays clear. That's a good sign. It means the motion was helping, but wasn't the only thing communicating progress.

uselayouts home

What makes a form feel "human"

I've been throwing this word around so let me try to pin it down. Here's what I think makes a form feel human instead of bureaucratic, from building this and from going through way too many onboarding flows.

It talks like a person. "Let's get you in" instead of "Account Registration." "Give your project a name" instead of "Field required." Every bit of copy in a form is part of the interaction.

It doesn't ask for things it doesn't need yet. If you don't need their phone number to create an account, don't ask during sign up. Every field is a small cost you're asking someone to pay.

It shows you where you are. Step dots, a progress label, a title per step. You should never wonder how much is left.

It never loses your work. Going back keeps your data. Errors don't wipe fields. Accidentally leaving the page warns you.

It responds to you. Selections light up. Tags appear when you add them. The card grows when there's more to show. Errors appear right where you're looking.

It moves in a direction that makes sense. Forward is forward. Back is back. The space is consistent.

It ends warmly. The last step is the last impression. Make it about them.

None of these are about animation, really. Most of them are about copy and structure and care. Animation is what makes the transitions between those caring moments feel continuous instead of choppy. That's the role motion plays here. It connects the dots.

A shipping night

Let me tell you about the night the Multi-Step Form almost broke me.

I had the basic structure working pretty fast. Three steps, Continue and Back, React Hook Form wired up. Took maybe an hour and a half. I was like, this is going to be an easy one.

Then I added the slide animation. And the card height went crazy. Every transition, the card would shoot up to about double height and then snap back down. I spent an hour thinking it was the measurement library. Then another half hour thinking it was the spring. It was neither. It was that both steps were in the layout flow at the same time during the transition, so the measured height was the sum of both. mode="popLayout". One prop. I was so relieved and so annoyed at the same time.

Then the direction thing. I had a single slide direction and Back felt wrong, so I added the custom direction prop. Except I only added it to the motion.div, not to AnimatePresence. So going forward was fine, going back was fine, but going forward and then back quickly would make the exiting step leave the wrong way. That took me a while to even reproduce reliably, because you had to click pretty fast. Once I added custom to AnimatePresence too, it was solid.

Then I found the tags Enter bug. I was testing tags, hit Enter, and the form submitted with a toast full of empty values. I sat there staring at the toast for a second like, why is my form done. preventDefault.

Then I spent way too long on the emojis. I tried a version where the selected emoji scaled up and bounced. It was cute for about four clicks and then really annoying. I tried a version where all of them were in color. Too loud. Grayscale with color on hover and selection is what stuck. It's quiet until you make a choice.

By around 3am the form felt the way I wanted. You could fly through it and it felt like turning pages. Going back felt like going back. The card breathed with the content.

And looking at the final code, the motion part is maybe twenty or thirty lines. The variants, the AnimatePresence, the measured height, the MotionConfig. Everything else is just a good form. Which I think is the right ratio. The animation should be a small layer that makes a solid thing feel better, not the foundation.

An idea I keep coming back to: the review step

One thing the demo doesn't have, and that I think a lot of real flows should, is a review step. Right before Finish, a quick summary of everything you entered. Project name, date, team size, priority, tags, mood. All on one screen.

Why bother? Because by step 3, step 1 is out of sight. You typed the project name a minute ago and you probably don't remember if you typo'd it. A review step gives people one calm moment to check before committing. It's the form version of reading your email once before hitting send.

The nice part is how little it takes with this component. Add a fourth entry to stepTitles ("Look good?" or something like that), add a case to the switch that reads from watchedValues and renders the values as plain text, and you're done. The slide, the height animation and the dots all just work, because they don't care what's inside a step.

And here's where other useLayouts pieces fit in nicely. Instead of making people click Back three times to fix a typo, you could make each line in the review step editable in place. That's basically what Inline Edit is for: a value that looks like text until you click the pencil, then becomes an input, then goes back to text with a check. Wire its value to form.setValue and the fix happens right there, no step hopping.

npx shadcn@latest add @uselayouts/inline-edit

I haven't shipped a combined "review step with inline edits" demo, so treat this as a recipe, not a component. But it's a good example of what copy, customize, ship actually means in practice. The components are pieces. You put them together into the flow your product needs. Nobody's registration flow looks exactly like my demo, and it shouldn't.

One more small thing if you do add a review step: make the Finish button live there, not on the step before. The moment someone sees everything they entered is the moment they're most ready to commit. Put the commit button right where that confidence is.

Where Vercel's program fits in

useLayouts is part of the Vercel Open Source Program Winter 2026 cohort, and Vercel wrote about the whole cohort here: https://vercel.com/blog/vercel-open-source-program-winter-2026-cohort

For something like the Multi-Step Form, the live demo on the site is the whole pitch. You can't really judge whether a form "feels like progress" from reading about it, even a post this long. You have to click Continue and Back and watch the card breathe. So keeping every demo live and fast matters a lot to me, and the credits and support from the program take a real worry off my plate while I focus on the components.

Also, just being picked for it was a nice reminder that the boring sounding stuff (like making forms feel good) is worth caring about. Forms aren't flashy. They're what people actually use.

Quick clarification since it comes up a lot: useLayouts (uselayouts.com) isn't the same project as UI Layouts (ui-layouts.com). Different people, different libraries, and yes we're both in Vercel's open source program, which makes it extra confusing. This post is about useLayouts.

FAQ

Can I use the Multi-Step Form without React Hook Form?

You could, since you own the code. But I'd recommend keeping it. A lot of the tricky stuff, like keeping values across unmounted steps and per field validation, comes basically for free with React Hook Form plus Zod. Replacing it means rebuilding that.

Does it work with server actions in Next.js?

Sure. Replace the body of onSubmit with a call to your server action. The form doesn't care where the data goes. Just make sure you handle loading and error states on the Finish button, so the last click gives honest feedback, not a fake success.

Can I add more steps?

Yes. Add entries to stepTitles, add cases to the content switch, and replace the hardcoded 2s in nextStep with stepTitles.length - 1. The animation and the dots adapt automatically.

Can steps be conditional? Like skip step 2 if they pick "solo"?

Yep, but you'll need to change how nextStep and prevStep compute the next index, instead of just adding or subtracting one. Keep the direction logic the same though. Skipping forward should still slide forward.

Why are all the fields optional in the demo?

So you can click through and see the transitions without filling anything in. For your app, make the schema real and add per step validation with trigger, like I showed above.

Why the emoji mood step? Isn't that a bit silly for a real app?

Maybe! For some products it's perfect, for others it's not. The point is the pattern: end with something light and human. Swap the emojis for whatever fits.

Is it free to use commercially?

Yes, MIT licensed. Use it in client projects, in your startup, wherever. Sponsorships from companies who get value out of it are welcome and help keep things going, but there's no catch.

The end (you made it through the whole form)

If there's one thing I hope you take from this, it's that progress is a feeling you can design. Direction, continuity, a stable frame, honest feedback, words that sound like a person. A multi-step form that gets these right can ask for a lot of information and still feel easy.

If you want to see it in action, go to the site and click through the Multi-Step Form demo. Go forward, go back, add some tags, pick a mood, watch the toast. Then pull it into your project and make it yours:

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

And if it helps you ship a sign up flow that people actually finish, a star on GitHub would mean a lot. We're at hundreds of stars and climbing, and every one helps other devs find this.

Thanks for reading. Go make a form someone actually enjoys.

Urvish

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.