<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Urvish</title>
    <description>The latest articles on DEV Community by Urvish (@urvish_16).</description>
    <link>https://dev.to/urvish_16</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169626%2F246effef-592c-4849-8f33-50412a19c4df.png</url>
      <title>DEV Community: Urvish</title>
      <link>https://dev.to/urvish_16</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/urvish_16"/>
    <language>en</language>
    <item>
      <title>Forms That Feel Like Progress: Building Multi-Step Registration That Doesn't Feel Like Paperwork</title>
      <dc:creator>Urvish</dc:creator>
      <pubDate>Thu, 08 Oct 2026 04:51:55 +0000</pubDate>
      <link>https://dev.to/urvish_16/forms-that-feel-like-progress-building-multi-step-registration-that-doesnt-feel-like-paperwork-21od</link>
      <guid>https://dev.to/urvish_16/forms-that-feel-like-progress-building-multi-step-registration-that-doesnt-feel-like-paperwork-21od</guid>
      <description>&lt;p&gt;Nobody wakes up excited to fill out a form.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why multi-step at all?
&lt;/h2&gt;

&lt;p&gt;Let me argue against myself for a second, because multi-step forms aren't always the answer.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Multi-step makes sense when:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The Multi-Step Form demo in useLayouts is a "Create New Project" flow. Three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create New Project.&lt;/strong&gt; Project name, due date, description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration.&lt;/strong&gt; Team size, priority, tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project Kickoff Mood.&lt;/strong&gt; An emoji mood picker and a comment box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flma0tgdqedzuib4izplk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flma0tgdqedzuib4izplk.png" alt="Multi-step form component" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing it
&lt;/h2&gt;

&lt;p&gt;If you've already got the useLayouts registry in your &lt;code&gt;components.json&lt;/code&gt;, you can skip this part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@uselayouts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://uselayouts.com/r/{name}.json"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/multi-step-form
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with your package manager of choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm dlx shadcn@latest add @uselayouts/multi-step-form
yarn dlx shadcn@latest add @uselayouts/multi-step-form
bunx &lt;span class="nt"&gt;--bun&lt;/span&gt; shadcn@latest add @uselayouts/multi-step-form
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: one card, sliding content, animated height
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Each of those three parts matters, so let me go through them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The card stays put
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The content slides, and it knows which way
&lt;/h3&gt;

&lt;p&gt;Here's the slide logic. It uses Motion's &lt;code&gt;custom&lt;/code&gt; prop to pass a direction into the variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you click Continue, direction is &lt;code&gt;1&lt;/code&gt;. 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 &lt;code&gt;-1&lt;/code&gt;, and everything flips. The previous step comes back in from the left, and the current one leaves to the right.&lt;/p&gt;

&lt;p&gt;That direction is set right before the step changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setCurrentStep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setCurrentStep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The height animates to fit
&lt;/h3&gt;

&lt;p&gt;This is the trickiest part technically, and it's the one that makes the biggest difference.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That's awful. Imagine pressing an elevator button and having the button move.&lt;/p&gt;

&lt;p&gt;So the content area measures itself and animates its height:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMeasure&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative overflow-hidden"&lt;/span&gt;
  &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bounce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CardContent&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"px-6 py-2 relative"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* sliding steps go here */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;CardContent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The &lt;code&gt;bounds.height &amp;gt; 0&lt;/code&gt; 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 &lt;code&gt;"auto"&lt;/code&gt; until there's a real measurement avoids that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bounce: 0&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  popLayout is doing a quiet job
&lt;/h3&gt;

&lt;p&gt;The steps are wrapped in &lt;code&gt;AnimatePresence&lt;/code&gt; with &lt;code&gt;mode="popLayout"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AnimatePresence&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"popLayout"&lt;/span&gt; &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"initial"&lt;/span&gt;
    &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"animate"&lt;/span&gt;
    &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"exit"&lt;/span&gt;
    &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-full"&lt;/span&gt;
    &lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AnimatePresence&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;popLayout&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;popLayout&lt;/code&gt; fixes that. Took me a whole evening to figure out that's what was going on.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;custom={direction}&lt;/code&gt; on &lt;code&gt;AnimatePresence&lt;/code&gt; 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 &lt;code&gt;custom&lt;/code&gt; to &lt;code&gt;AnimatePresence&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  One timing for everything
&lt;/h3&gt;

&lt;p&gt;The whole form is wrapped in &lt;code&gt;MotionConfig&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MotionConfig&lt;/span&gt;
  &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bounce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step indicator: dots that stretch
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center gap-1.5 pt-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stepTitles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h-2 rounded-full transition-all duration-300&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;w-8 bg-primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;w-2 bg-primary/20&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is plain CSS transitions, not Motion. Not everything needs a spring. A width and color transition is plenty for this.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steps themselves
&lt;/h2&gt;

&lt;p&gt;Now let me walk through each step, because there are some small details in each one I'm kind of proud of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: the easy start
&lt;/h3&gt;

&lt;p&gt;"Create New Project. Start by providing the essential details for your workspace."&lt;/p&gt;

&lt;p&gt;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":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PopoverTrigger&lt;/span&gt;
  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;w-full justify-start text-left font-normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;watchedValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-muted-foreground&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CalendarIcon&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mr-2 h-4 w-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;watchedValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watchedValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PPP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pick a date&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;date-fns&lt;/code&gt;'s &lt;code&gt;format&lt;/code&gt; with &lt;code&gt;"PPP"&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the thinking step
&lt;/h3&gt;

&lt;p&gt;"Configuration. Define team access and project priority settings."&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TEAM_SIZE_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Select team size&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1-5 Members&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5-10 Members&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5-10&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10+ Members&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRIORITY_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Select priority&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;High&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;High&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tag"&lt;/span&gt;
  &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"e.g. Design, Marketing"&lt;/span&gt;
  &lt;span class="na"&gt;onKeyDown&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of things in here that came from mistakes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;e.preventDefault()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.trim()&lt;/code&gt; and the &lt;code&gt;if (val)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;!tags.includes(val)&lt;/code&gt; 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."&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 3: the human step
&lt;/h3&gt;

&lt;p&gt;"Project Kickoff Mood. How confident do you feel about this new project?"&lt;/p&gt;

&lt;p&gt;This is the step I had the most fun with, and it's the one that people seem to remember from the demo.&lt;/p&gt;

&lt;p&gt;It's a row of five emojis, from anxious to excited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;😰&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anxious&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Anxious&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;😟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;worried&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Worried&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;😐&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🙂&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;good&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Good&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🤩&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;excited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Excited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex-1 p-3 md:p-4 text-2xl md:text-3xl transition-all hover:bg-muted focus:outline-none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;watchedValues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mood&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-primary/10 grayscale-0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grayscale-[1] hover:grayscale-0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;I want to talk about why this step exists, because it's not really about emojis.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Real talk on accessibility though: emoji buttons have a &lt;code&gt;title&lt;/code&gt; attribute with the label, but I'd add proper &lt;code&gt;aria-label&lt;/code&gt;s and an &lt;code&gt;aria-pressed&lt;/code&gt; state in production so screen readers announce what each one is and which one's selected. And &lt;code&gt;focus:outline-none&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The finish
&lt;/h3&gt;

&lt;p&gt;On the last step, the Continue button turns into Finish with a check icon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;nextStep&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;stepTitles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      Finish &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Check&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-4 w-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      Continue &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChevronRight&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-4 w-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Back button is disabled on step 1, since there's nowhere to go back to.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;pre&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-2 w-[340px] rounded-md bg-slate-950 p-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;code&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-white"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;code&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing about validation (please read this part)
&lt;/h2&gt;

&lt;p&gt;Okay, here's the honest bit. In the demo, every field in the Zod schema is optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team-size&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;mood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;If you just make fields required and keep the same &lt;code&gt;nextStep&lt;/code&gt; 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 &lt;code&gt;handleSubmit&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;The fix is to validate each step before moving forward. React Hook Form has &lt;code&gt;trigger&lt;/code&gt;, which validates specific fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stepFields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="nx"&gt;FormValues&lt;/span&gt;&lt;span class="p"&gt;)[][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team-size&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;priority&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mood&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;comment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stepFields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;stepFields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setCurrentStep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And then make the schema real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Give your project a name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team-size&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refine&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pick a team size&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into a real registration flow
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That's seven fields. On one page, it's a wall. As three steps, it can feel like a conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: "Let's get you in."&lt;/strong&gt; 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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: "Tell us about your workspace."&lt;/strong&gt; Name, workspace name, team size. Workspace name and team size could be side by side, same as the selects in the demo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: "One last thing."&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Here's the shape of the changes in the actual file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Change Here&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stepTitles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Let's get you in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Just your email and a password to start.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your workspace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is where your team will work together.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;One last thing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Totally optional, but it helps us help you.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Also, if you have more than three steps, a few things in the demo are hardcoded to three, like &lt;code&gt;currentStep === 2&lt;/code&gt; and &lt;code&gt;currentStep &amp;lt; 2&lt;/code&gt; in &lt;code&gt;nextStep&lt;/code&gt;. Swap those for &lt;code&gt;stepTitles.length - 1&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes I've hit (or seen coming)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Focus gets lost between steps
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;currentStep&lt;/code&gt; 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 &lt;code&gt;focus({ preventScroll: true })&lt;/code&gt; helps.&lt;/p&gt;

&lt;p&gt;Also, consider announcing the step change for screen readers. An &lt;code&gt;aria-live="polite"&lt;/code&gt; region with the step title is a simple way.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Enter key
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;type="button"&lt;/code&gt; and Finish calls &lt;code&gt;handleSubmit&lt;/code&gt; directly), so it's less of a risk. But if you wrap it in a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser back button
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;?step=2&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data on unmounted steps
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;shouldUnregister&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;But if you ever flip that setting, or move some field into local &lt;code&gt;useState&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The height animation and dynamic content
&lt;/h3&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;The good news is &lt;code&gt;useMeasure&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mobile keyboards
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reduced motion
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;MotionConfig&lt;/code&gt;, you can add &lt;code&gt;reducedMotion="user"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MotionConfig&lt;/span&gt;
  &lt;span class="na"&gt;reducedMotion&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt;
  &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bounce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncga7ygbydym9pf8bf30.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncga7ygbydym9pf8bf30.png" alt="uselayouts home" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a form feel "human"
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It talks like a person.&lt;/strong&gt; "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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't ask for things it doesn't need yet.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It shows you where you are.&lt;/strong&gt; Step dots, a progress label, a title per step. You should never wonder how much is left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It never loses your work.&lt;/strong&gt; Going back keeps your data. Errors don't wipe fields. Accidentally leaving the page warns you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It responds to you.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It moves in a direction that makes sense.&lt;/strong&gt; Forward is forward. Back is back. The space is consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It ends warmly.&lt;/strong&gt; The last step is the last impression. Make it about them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shipping night
&lt;/h2&gt;

&lt;p&gt;Let me tell you about the night the Multi-Step Form almost broke me.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;mode="popLayout"&lt;/code&gt;. One prop. I was so relieved and so annoyed at the same time.&lt;/p&gt;

&lt;p&gt;Then the direction thing. I had a single slide direction and Back felt wrong, so I added the &lt;code&gt;custom&lt;/code&gt; direction prop. Except I only added it to the &lt;code&gt;motion.div&lt;/code&gt;, not to &lt;code&gt;AnimatePresence&lt;/code&gt;. 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 &lt;code&gt;custom&lt;/code&gt; to &lt;code&gt;AnimatePresence&lt;/code&gt; too, it was solid.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;preventDefault&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And looking at the final code, the motion part is maybe twenty or thirty lines. The variants, the &lt;code&gt;AnimatePresence&lt;/code&gt;, the measured height, the &lt;code&gt;MotionConfig&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  An idea I keep coming back to: the review step
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The nice part is how little it takes with this component. Add a fourth entry to &lt;code&gt;stepTitles&lt;/code&gt; ("Look good?" or something like that), add a case to the switch that reads from &lt;code&gt;watchedValues&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;form.setValue&lt;/code&gt; and the fix happens right there, no step hopping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/inline-edit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Vercel's program fits in
&lt;/h2&gt;

&lt;p&gt;useLayouts is part of the Vercel Open Source Program Winter 2026 cohort, and Vercel wrote about the whole cohort here: &lt;a href="https://vercel.com/blog/vercel-open-source-program-winter-2026-cohort" rel="noopener noreferrer"&gt;https://vercel.com/blog/vercel-open-source-program-winter-2026-cohort&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use the Multi-Step Form without React Hook Form?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work with server actions in Next.js?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sure. Replace the body of &lt;code&gt;onSubmit&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I add more steps?&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can steps be conditional? Like skip step 2 if they pick "solo"?&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why are all the fields optional in the demo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;trigger&lt;/code&gt;, like I showed above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the emoji mood step? Isn't that a bit silly for a real app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it free to use commercially?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The end (you made it through the whole form)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/multi-step-form
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://uselayouts.com/" rel="noopener noreferrer"&gt;https://uselayouts.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://uselayouts.com/docs/introduction" rel="noopener noreferrer"&gt;https://uselayouts.com/docs/introduction&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/iurvish/uselayouts" rel="noopener noreferrer"&gt;https://github.com/iurvish/uselayouts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. Go make a form someone actually enjoys.&lt;/p&gt;

&lt;p&gt;Urvish&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>tailwindcss</category>
      <category>ux</category>
    </item>
    <item>
      <title>Copy, Customize, Ship: Why useLayouts Isn't a Locked npm UI Kit</title>
      <dc:creator>Urvish</dc:creator>
      <pubDate>Wed, 07 Oct 2026 18:09:26 +0000</pubDate>
      <link>https://dev.to/urvish_16/copy-customize-ship-why-uselayouts-isnt-a-locked-npm-ui-kit-1kbg</link>
      <guid>https://dev.to/urvish_16/copy-customize-ship-why-uselayouts-isnt-a-locked-npm-ui-kit-1kbg</guid>
      <description>&lt;p&gt;There's a specific kind of frustration I think every frontend dev has felt at least once.&lt;/p&gt;

&lt;p&gt;You install a UI library. It's great for the first week. Everything looks consistent, the docs are nice, you're moving fast. Then your designer, or your own brain, asks for one small change. The dropdown should open a little slower. The tab indicator should be rounder. The button shouldn't scale on hover, it should do this other thing.&lt;/p&gt;

&lt;p&gt;And you go looking for the prop. There's no prop. You look for a theme override. There's a theme override, but it only covers colors. You look at the source on GitHub and find the animation is hardcoded three components deep. You think about forking. You think about &lt;code&gt;!important&lt;/code&gt;. You think about wrapping it in a div and fighting it with CSS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2xjhnvhqba8lcl8nbbl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2xjhnvhqba8lcl8nbbl.png" alt="Browse wall of useLayouts components" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2xjhnvhqba8lcl8nbbl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2xjhnvhqba8lcl8nbbl.png" alt="Browse wall of useLayouts components" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An hour later you've written forty lines of override code to change one number.&lt;/p&gt;

&lt;p&gt;That experience is a big part of why useLayouts works the way it does. When you add a component, you don't install a package. You get a file. It's yours. Change the number.&lt;/p&gt;

&lt;p&gt;I'm Urvish, I make useLayouts. It's a free, MIT licensed collection of animated React components built with Tailwind and Motion, distributed as a shadcn registry. It got picked for the Vercel Open Source Program Winter 2026 cohort, which I'm still kind of processing. This post is about one specific idea: copy, customize, ship. Why I chose it over the traditional npm package model, what it's actually like to live with, where it's worse, and how I'd recommend using it so you don't end up with a mess.&lt;/p&gt;

&lt;p&gt;I'm going to try to be fair here. Locked packages aren't evil. There are real reasons they exist. But for the kind of components I make, I think owning the source is the better deal, and I want to show you why with real examples instead of vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two models, plainly
&lt;/h2&gt;

&lt;p&gt;Let's define terms so we're talking about the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The package model.&lt;/strong&gt; You run &lt;code&gt;npm install some-ui-kit&lt;/code&gt;. The library lives in &lt;code&gt;node_modules&lt;/code&gt;. You import components from it. You configure them through props, theme objects, or CSS variables the library chose to expose. When the library updates, you bump the version and get the new behavior. You don't really own the code. You rent it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The copy model.&lt;/strong&gt; You run a command, or literally copy paste, and the component's source code ends up in your project. Usually in something like &lt;code&gt;components/ui/&lt;/code&gt;. It's a normal file. It imports its dependencies like any other file. You can edit it directly. When the original author updates it, nothing changes in your project unless you go get the new version yourself.&lt;/p&gt;

&lt;p&gt;Shadcn/ui made the copy model mainstream. Before that, a lot of people were doing it informally, copying snippets from blog posts and CodePens. Shadcn turned it into a real workflow with a CLI and a registry format. And now other people, like me, can publish registries that plug into the same CLI.&lt;/p&gt;

&lt;p&gt;For useLayouts, it looks like this. You add the registry once in &lt;code&gt;components.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@uselayouts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://uselayouts.com/r/{name}.json"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you add components by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/discrete-tabs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file lands in your project. Motion and whatever else it needs get installed for you as regular dependencies. That's it. There's no &lt;code&gt;uselayouts&lt;/code&gt; package in your &lt;code&gt;package.json&lt;/code&gt;. There's no runtime that belongs to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why motion components specifically want to be owned
&lt;/h2&gt;

&lt;p&gt;I think the copy model is good in general, but I think it's especially good for animated components. Here's my argument.&lt;/p&gt;

&lt;p&gt;A regular button is mostly structure and accessibility. It needs to be a real button element, it needs focus styles, it needs disabled states, it needs to handle keyboard input. Those things have right answers. There's a correct way to do them, and a library that does them correctly is genuinely valuable, because you probably don't want to reinvent that.&lt;/p&gt;

&lt;p&gt;Motion is different. Motion is taste.&lt;/p&gt;

&lt;p&gt;What's the right spring stiffness for a tab indicator? There isn't one. It depends on your brand. A meditation app and a trading dashboard should not have the same tab animation. It depends on your users' devices. It depends on how much else is moving on the page. It depends on whether your designer likes things snappy or soft.&lt;/p&gt;

&lt;p&gt;If I ship a locked component with a hardcoded spring, I'm forcing my taste on your product. If I expose the spring as a prop, okay, now you can change stiffness. But what about damping? Mass? What about the blur on the label? What about the fact that you want the icon to move but not the text? What about the fact that you want the label to just appear, no blur at all?&lt;/p&gt;

&lt;p&gt;Every one of those becomes a prop. The API balloons. And it's still never enough, because the one thing you want to change is always the one thing I didn't think to expose.&lt;/p&gt;

&lt;p&gt;With the copy model, I don't have to predict what you'll want. You open the file and change it. The spring is right there. The blur is right there. Delete it. Done.&lt;/p&gt;

&lt;p&gt;That's why I say, half seriously, that the components are a starting point you can ruin on purpose. I'm not giving you a finished product. I'm giving you a really good first draft that you finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real example: Discrete Tabs out of the box
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkmjh9uyxr9dsb8v6fwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkmjh9uyxr9dsb8v6fwb.png" alt="useLayouts home hero" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkmjh9uyxr9dsb8v6fwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkmjh9uyxr9dsb8v6fwb.png" alt="useLayouts home hero" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's look at an actual component so this isn't abstract. Discrete Tabs is probably the one people add most, and it's a good one to show because it's short.&lt;/p&gt;

&lt;p&gt;When you add it, the file has a few icon components at the top, then this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Change Here&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TABS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Inbox&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Calendar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Alert&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;// Change Here&lt;/code&gt; comment is very intentional. It's the first thing you'll want to edit, so I put a sign on it.&lt;/p&gt;

&lt;p&gt;Then the main component, which is just state and a map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DiscreteTabs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;activeButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setActiveButton&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TABS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex gap-4 items-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TABS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;ButtonIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icon&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeButton&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;setActiveButton&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setActiveButton&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then a &lt;code&gt;Button&lt;/code&gt; function that does the actual motion work. Each tab is a pill. Inactive tabs show just the icon. The active tab opens up to show the icon plus the label. When you switch, the pills resize and slide using Motion's layout animations, and the label fades in with a quick blur.&lt;/p&gt;

&lt;p&gt;The spring looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;transition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;damping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stiffness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;230&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's not there. There's no &lt;code&gt;&amp;lt;DiscreteTabs springConfig={...} labelAnimation={...} labelBlur={false} /&amp;gt;&lt;/code&gt; API. There's no config object. It's just the code that does the thing, written as plainly as I could.&lt;/p&gt;

&lt;p&gt;Some people look at that and think it's less "professional" than a configurable component. I think it's more honest. You can read the whole thing in a couple of minutes, and then you know exactly what you're shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customizing it: five real edits
&lt;/h2&gt;

&lt;p&gt;Okay, so you've added Discrete Tabs. Here's what customizing actually looks like. These are edits I've made myself in real projects, or that people have asked me how to do. None of them need anything from me. That's the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit 1: Make it calmer
&lt;/h3&gt;

&lt;p&gt;Say your product is calmer than my demo. Maybe it's a writing app, or something people use for hours and you don't want the UI feeling bouncy.&lt;/p&gt;

&lt;p&gt;Open the file, find the spring, and change it. Lower stiffness makes it slower to get going. Higher damping makes it settle without overshoot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;damping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;stiffness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try it, click around, adjust. There's no right answer, there's just "does this feel like my product." I'd recommend changing one number at a time, because if you change all three at once you won't know which one made it feel better or worse.&lt;/p&gt;

&lt;p&gt;With a locked package, this edit either needs a prop that may not exist, or a fork. Here it's a ten second change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit 2: Make it controlled and hook it to your router
&lt;/h3&gt;

&lt;p&gt;The default version keeps its own state with &lt;code&gt;useState&lt;/code&gt;. That's great for a demo, but in a real app your tabs probably need to match the URL, or a parent component needs to know which tab is active.&lt;/p&gt;

&lt;p&gt;So you change the main component to take props:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DiscreteTabsProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DiscreteTabs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onValueChange&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;DiscreteTabsProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex gap-4 items-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TABS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;ButtonIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icon&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;setActiveButton&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the parent owns the state. You could wire that to a search param, a route segment, or whatever store you use. You'd also want to tidy up the &lt;code&gt;Button&lt;/code&gt; prop types so &lt;code&gt;setActiveButton&lt;/code&gt; is just a function that takes no arguments, but that's a two line change.&lt;/p&gt;

&lt;p&gt;This is a really common edit and it's a good example of why I don't try to make the defaults do everything. If I'd shipped a controlled and uncontrolled API with every possible integration, the file would be three times longer and harder to read. Instead, it's short, and you add the shape your app needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit 3: Clean out what you don't use
&lt;/h3&gt;

&lt;p&gt;If you read the &lt;code&gt;Button&lt;/code&gt; function closely, you'll find a &lt;code&gt;showShine&lt;/code&gt; state and a &lt;code&gt;useEffect&lt;/code&gt; that flips it on for 800ms whenever a tab becomes active after you've interacted. Then you'll notice nothing in the render actually reads &lt;code&gt;showShine&lt;/code&gt;. It's a hook for a shine effect that isn't drawn in the current file. Honestly, it's leftover scaffolding from me experimenting.&lt;/p&gt;

&lt;p&gt;In a package, that kind of thing would just sit in &lt;code&gt;node_modules&lt;/code&gt; forever and you'd never know. Here, you can see it. So you get to choose. Either use it, like conditionally rendering a little highlight sweep while &lt;code&gt;showShine&lt;/code&gt; is true, or delete the state and the effect. It's a &lt;code&gt;useState&lt;/code&gt; and a &lt;code&gt;useEffect&lt;/code&gt; with a timeout. Remove them and you're done.&lt;/p&gt;

&lt;p&gt;Same goes for the label blur. If your product wants the label to just appear, remove the &lt;code&gt;filter&lt;/code&gt; values from &lt;code&gt;initial&lt;/code&gt; and &lt;code&gt;animate&lt;/code&gt;. You don't need to set &lt;code&gt;labelBlur={false}&lt;/code&gt;. You don't need to file a feature request asking me to make it optional. You just delete the lines.&lt;/p&gt;

&lt;p&gt;I kind of like that this example is a little embarrassing for me. It's the most honest demo of the copy model I can give you. You see everything, including my leftovers.&lt;/p&gt;

&lt;p&gt;I know that sounds obvious, but I think a lot of developers are trained by package culture to not touch the component. It feels like you're breaking something. You're not. It's your file now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit 4: Use your icons
&lt;/h3&gt;

&lt;p&gt;The default file defines a few small SVG icon components at the top, so it doesn't force an icon library on you. But a lot of projects already use Lucide, and Lucide fits shadcn projects really naturally.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Button&lt;/code&gt; expects an icon component that accepts a &lt;code&gt;size&lt;/code&gt; prop. Lucide icons accept &lt;code&gt;size&lt;/code&gt;. So you can basically swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Inbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Bell&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TABS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Inbox&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Calendar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bell&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then delete the inline SVG components you're not using anymore. The file gets shorter and it matches the rest of your app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit 5: Match your design system
&lt;/h3&gt;

&lt;p&gt;The pills use theme classes like &lt;code&gt;bg-secondary&lt;/code&gt;, &lt;code&gt;outline-border&lt;/code&gt;, and &lt;code&gt;text-primary&lt;/code&gt; for the active state. If you're on a standard shadcn theme, those already pick up your colors, which is why I use them.&lt;/p&gt;

&lt;p&gt;But maybe your design system has its own rules. Maybe your tabs aren't pills, they're more rectangular. Maybe you don't use monospace uppercase labels, because that's a pretty specific look I like and you might not.&lt;/p&gt;

&lt;p&gt;The border radius is set inline as &lt;code&gt;borderRadius: "25px"&lt;/code&gt;. Change it. The label has &lt;code&gt;font-mono uppercase&lt;/code&gt;. Remove those classes and it'll use your normal font. The shadow is &lt;code&gt;shadow-md&lt;/code&gt;. Swap it for whatever your system uses, or drop it.&lt;/p&gt;

&lt;p&gt;Fun detail on the radius: it's set as an inline style instead of a Tailwind class on purpose. Motion's layout animations can correct border radius during the animation when it's set that way, so the corners don't look stretched while the pill resizes. If you move the radius into a class and the corners start looking weird mid animation, that's why. This is exactly the kind of thing that's easy to understand when you can read the file, and really confusing when it's buried in a package.&lt;/p&gt;

&lt;p&gt;After those five edits, the component might look nothing like my version. Good. That's what I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The night I fought a library
&lt;/h2&gt;

&lt;p&gt;I want to tell a story about the package model going wrong for me, because I think it explains why I care about this so much.&lt;/p&gt;

&lt;p&gt;A while back, before useLayouts was a real thing, I was building a dashboard for a project. I was using a popular component package for a lot of the UI. I'm not going to name it, because it's a good library and this isn't its fault. It just wasn't built for what I wanted.&lt;/p&gt;

&lt;p&gt;The dashboard had a sidebar with sections, and I wanted the active section indicator to slide between items instead of jumping. Pretty normal request.&lt;/p&gt;

&lt;p&gt;The library's nav component didn't support that. It had an active state, and you could style it with a class, but the indicator was just a background on the active item. No shared element, no transition between items.&lt;/p&gt;

&lt;p&gt;So I tried to add it myself. I wrapped the nav items, measured their positions with refs, and absolutely positioned a separate indicator element behind them. Then I animated that element's position when the active item changed.&lt;/p&gt;

&lt;p&gt;It kind of worked. Then I resized the window and the measurements went stale. So I added a resize observer. Then the library's own focus ring started rendering on top of my indicator in a weird way. So I overrode the focus styles. Then I realized the library re-rendered the items in a way that occasionally reset my refs. So I added some janky effect logic to re-measure.&lt;/p&gt;

&lt;p&gt;By around 2am I had something like a hundred lines of code whose only job was to fight the library into doing a slide animation. And it was fragile. Every time the library updated, I was scared it'd break.&lt;/p&gt;

&lt;p&gt;If I'd owned the nav component's source, the whole thing would have been: add &lt;code&gt;layoutId&lt;/code&gt; to the active indicator, done. Motion handles the shared layout animation. Maybe ten lines.&lt;/p&gt;

&lt;p&gt;That's the core of my argument. The package model is great when the package does what you want. When it doesn't, the cost of bending it can be way bigger than the cost of just owning the code from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;I promised to be fair, so here's the real list of downsides. If someone tells you the copy model has no tradeoffs, they're selling something.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updates don't come to you
&lt;/h3&gt;

&lt;p&gt;If I fix a bug in a component, or improve it, your copy doesn't change. You have to go get it.&lt;/p&gt;

&lt;p&gt;For some people that's a dealbreaker. They want &lt;code&gt;npm update&lt;/code&gt; and done. I get it.&lt;/p&gt;

&lt;p&gt;My take is that for this specific kind of component, it's actually a feature. Motion is something you tuned. If I could push an update that silently changed your spring, or your timing, or removed an effect, I could change how your product feels without you knowing. That seems worse than missing a fix.&lt;/p&gt;

&lt;p&gt;But it does mean you have to care a little. More on how to handle that below.&lt;/p&gt;

&lt;h3&gt;
  
  
  You can break it
&lt;/h3&gt;

&lt;p&gt;When you own the file, nothing stops you from making it worse. You can delete the &lt;code&gt;"use client"&lt;/code&gt; directive and get confused errors in the App Router. You can remove something that looked unnecessary but was doing important work, like that &lt;code&gt;isLoaded&lt;/code&gt; flag that stops the component animating on first render. You can introduce bugs.&lt;/p&gt;

&lt;p&gt;That's the cost of freedom. I try to make the files readable so you can understand what each piece is for before you remove it. But I can't stop you, and I wouldn't want to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drift across projects
&lt;/h3&gt;

&lt;p&gt;If you use useLayouts in five projects and customize it differently in each, you now have five versions. Some will have fixes the others don't. This is exactly the copy paste problem I had before useLayouts existed, when I was dragging a folder between repos.&lt;/p&gt;

&lt;p&gt;On a team, this can get messy if nobody owns it. Two developers each add the same component to different parts of the app and tweak it differently, and now your app has two slightly different tab animations.&lt;/p&gt;

&lt;h3&gt;
  
  
  More code in your repo
&lt;/h3&gt;

&lt;p&gt;Your project gets bigger. Each component is a real file you have to maintain, lint, type check, and review. With a package, that code is in &lt;code&gt;node_modules&lt;/code&gt; and it's somebody else's problem.&lt;/p&gt;

&lt;p&gt;For most useLayouts components this is pretty small, a single file, but it's still real. If you add twenty components you have twenty files you're responsible for.&lt;/p&gt;

&lt;h3&gt;
  
  
  You need to read the code
&lt;/h3&gt;

&lt;p&gt;This is a soft one but it matters. The copy model works best when you actually open the file and understand it. If you're the kind of developer who wants to never look inside a component, a package might suit you better.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to live with it without making a mess
&lt;/h2&gt;

&lt;p&gt;So, given those tradeoffs, here's how I'd actually recommend working with copy model components. This is what I do in my own projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit the pristine version first
&lt;/h3&gt;

&lt;p&gt;When you add a component, commit it immediately, before you change anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/discrete-tabs
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"add discrete-tabs from uselayouts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make your edits and commit those separately. Now your history has a clean record of what came from the registry and what you changed. When you want to pull an update later, this makes it way easier to see what's mine and what's yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pulling an update
&lt;/h3&gt;

&lt;p&gt;When there's a newer version of a component you want, the CLI can add it again. It'll ask before overwriting an existing file. My workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make sure my working tree is clean.&lt;/li&gt;
&lt;li&gt;Re-add the component and let it overwrite.&lt;/li&gt;
&lt;li&gt;Look at the git diff. Now I can see exactly what changed between my version and the new one.&lt;/li&gt;
&lt;li&gt;Keep the upstream changes I want, restore my customizations, commit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's more manual than a version bump, yeah. But it's also way more transparent. You see every single line that changes. Nothing sneaks in.&lt;/p&gt;

&lt;p&gt;Honestly for a lot of components you'll never need to do this. If it works and it feels right in your product, there's no reason to update it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep one copy per app
&lt;/h3&gt;

&lt;p&gt;On a team, agree on one location and one version of each component. If the dashboard and the settings page both need Discrete Tabs, they should import the same file, not each have their own. If one needs different behavior, add a prop or a variant to the shared file. That's normal component hygiene, it just matters more when the component didn't come from a package.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rename it if you changed it a lot
&lt;/h3&gt;

&lt;p&gt;If you've customized a component so much it's basically a different thing, rename it. &lt;code&gt;discrete-tabs.tsx&lt;/code&gt; becomes &lt;code&gt;section-switcher.tsx&lt;/code&gt; or whatever it actually is in your app. That way nobody on your team thinks it's the stock version, and nobody accidentally overwrites it by re-adding from the registry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leave a note about where it came from
&lt;/h3&gt;

&lt;p&gt;A one line comment at the top of the file saying it started as a useLayouts component is nice. It helps future you remember where to look for updates, and it helps teammates understand why it's written the way it is. MIT doesn't make you do this in a comment specifically, but keeping the attribution around is good practice and I appreciate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a locked package is actually the better choice
&lt;/h2&gt;

&lt;p&gt;I don't want to pretend the copy model wins every time. There are cases where I'd reach for a regular package myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard, correctness heavy primitives.&lt;/strong&gt; Things like a fully accessible combobox, a date picker with keyboard navigation and screen reader support, focus trapping in dialogs. These have a lot of edge cases and a "right" answer. You want those to be maintained centrally by people who've thought about every weird assistive tech quirk. That's what Radix is great at. Even shadcn components that you copy into your project usually wrap Radix primitives that stay as a package dependency. The copy layer is the styling and composition. The hard behavior stays in a maintained package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Huge surface area you'll never customize.&lt;/strong&gt; If you need fifty basic components and you're happy with how they look, a package is less code in your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams with zero appetite for owning UI code.&lt;/strong&gt; If nobody on the team wants to read component source, ever, the copy model will slowly rot. A package with a stable API might be a better fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things with security or protocol implications.&lt;/strong&gt; Auth flows, payment widgets, anything where you really want the vendor's fixes to arrive automatically.&lt;/p&gt;

&lt;p&gt;So the way I think about it: the deep, correctness heavy stuff can live in packages. The surface layer, the part that defines how your product feels, should live in your code. useLayouts is entirely that surface layer. It's tabs, buttons, cards, galleries, small interactions. It's the feel. That's exactly the part you should own.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can actually see what you're shipping
&lt;/h2&gt;

&lt;p&gt;This one's underrated. When a component is a file in your project, you can read every line of it before it ships to your users.&lt;/p&gt;

&lt;p&gt;With a package, you usually trust it. You check that it's popular, maybe glance at the GitHub, and import it. What's actually inside? Its own dependencies, its own effects, maybe some global CSS it injects, maybe a polyfill. You probably never look.&lt;/p&gt;

&lt;p&gt;With a useLayouts component, the whole thing is right there. You can see which dependencies it pulls in, because the registry item declares them. For Discrete Tabs that's &lt;code&gt;motion&lt;/code&gt;, &lt;code&gt;clsx&lt;/code&gt;, and &lt;code&gt;tailwind-merge&lt;/code&gt;. You can see every effect, every timeout, every style. Nothing hidden.&lt;/p&gt;

&lt;p&gt;I also put the source on every component's docs page, so you can read it before you even run the CLI. I really don't want anyone to be surprised by what lands in their project. If you read the file and something seems off, you can not add it. That's a level of control you just don't get with a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "ship" actually means here
&lt;/h2&gt;

&lt;p&gt;The name is copy, customize, ship. I've talked a lot about copy and customize. Let me talk about the ship part, because that's the part that matters.&lt;/p&gt;

&lt;p&gt;The whole reason this model exists is so you can ship faster without shipping something dead. Not so you can spend a week tuning springs. If copying a component turns into an endless customization rabbit hole, that's a failure too.&lt;/p&gt;

&lt;p&gt;So here's a real shipping night, or close to one, to show how it plays out.&lt;/p&gt;

&lt;p&gt;I was helping out on a small project that needed an admin table where you could delete rows. It was late, the deadline was the next morning, and the delete action was just a red button with a browser confirm dialog. It worked. It felt like 2009.&lt;/p&gt;

&lt;p&gt;Here's what I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/delete-button
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit. Then I opened the file and did three things.&lt;/p&gt;

&lt;p&gt;First, I changed the copy. My demo text didn't match their product's voice, so I rewrote the labels to match what they used everywhere else.&lt;/p&gt;

&lt;p&gt;Second, I hooked up the actual delete. The demo version counts down and gives you a cancel option, but it doesn't actually delete anything, because there's nothing to delete on a docs page. I made the real mutation fire when the countdown finishes, and made cancel actually cancel.&lt;/p&gt;

&lt;p&gt;Third, I shortened the countdown. The timing values all sit in one config object at the top of the file, which made this a one number change. In a table with lots of rows, you see the delete button a lot, and anything long starts to feel slow when you're deleting five things in a row.&lt;/p&gt;

&lt;p&gt;That's it. Maybe forty minutes including testing. The browser confirm was gone, deleting felt deliberate, and the table still felt fast. Shipped it, went to bed.&lt;/p&gt;

&lt;p&gt;The key thing: I didn't redesign the component. I didn't try to make it perfect. I copied it, made the three edits that mattered for that product, and shipped. The customization was in service of shipping, not a hobby.&lt;/p&gt;

&lt;p&gt;I think that's the healthy way to use this stuff. Make the edits that make it yours. Stop there. You can always come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle size and dependencies
&lt;/h2&gt;

&lt;p&gt;People sometimes ask whether adding a bunch of animated components will bloat their app. Fair question.&lt;/p&gt;

&lt;p&gt;The main dependency is Motion. If your project already uses it, adding useLayouts components barely changes anything, because they all share it. If it doesn't, you're adding Motion once, and every component after that reuses it.&lt;/p&gt;

&lt;p&gt;Beyond that, the components themselves are small files. And because they're your code, your bundler treats them like any other component. Tree shaking works normally. If you add a component and never import it, it doesn't end up in your bundle. If you only use it on one page, code splitting can keep it on that page.&lt;/p&gt;

&lt;p&gt;That's actually another quiet advantage over some packages. With a big monolithic UI package, you're sometimes at the mercy of how well it's set up for tree shaking. With source in your project, the bundler sees exactly what you use.&lt;/p&gt;

&lt;p&gt;And if you're worried about a specific component, you can open it and see what it imports. No guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this plays with shadcn and Radix
&lt;/h2&gt;

&lt;p&gt;useLayouts isn't trying to replace shadcn/ui. It sits next to it.&lt;/p&gt;

&lt;p&gt;A typical setup I'd recommend: use shadcn for your base components. Buttons, inputs, dialogs, dropdowns, the stuff every app needs. Those give you solid structure and accessibility, often through Radix under the hood. Then use useLayouts for the moments you want to feel special. The main nav tabs. The delete action. The save button. The pricing card on your landing page. The gallery on your portfolio.&lt;/p&gt;

&lt;p&gt;Because useLayouts components follow the same conventions, they fit right in. Same &lt;code&gt;components.json&lt;/code&gt;. Same CLI. Same &lt;code&gt;cn&lt;/code&gt; helper from &lt;code&gt;@/lib/utils&lt;/code&gt;. Same theme tokens. They don't feel like a foreign library dropped into your app. They feel like more of your components, because they are.&lt;/p&gt;

&lt;p&gt;It's also why I kept the "add the registry once" setup so short. You've probably already got a &lt;code&gt;components.json&lt;/code&gt; from shadcn. Adding useLayouts is one more line in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  On teams: who owns the motion
&lt;/h2&gt;

&lt;p&gt;Something I've seen when teams use copy model components: the question of who owns the motion becomes really clear, really fast.&lt;/p&gt;

&lt;p&gt;With a package, the answer is basically "the package author." Your team just accepts whatever animations ship with it. Nobody on the team really decides how your tabs feel. It just is what it is.&lt;/p&gt;

&lt;p&gt;When the code is in your repo, someone has to own it. And I think that's healthy. Somebody on your team becomes the person who cares about how the UI moves. They tune the springs, they make sure the timing is consistent across components, they review PRs that touch animations.&lt;/p&gt;

&lt;p&gt;A practical tip if you're on a team: pull your shared motion values into one place once you've customized a few components. Like a small file with your app's springs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;springs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;snappy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;damping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stiffness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;calm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;damping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stiffness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update the components you've added to use those instead of their inline values. Now your whole app moves with the same personality, and changing it is one edit.&lt;/p&gt;

&lt;p&gt;I don't ship components that way by default, because I want each file to be self contained and readable on its own. If Discrete Tabs imported springs from some shared file you don't have, it'd break when you added it. But once it's in your project, pulling values out like that is totally the right move.&lt;/p&gt;

&lt;p&gt;That's kind of the copy model in a nutshell. I give you something self contained. You integrate it into your system however makes sense for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not offer both?
&lt;/h2&gt;

&lt;p&gt;Someone asked me once why I don't just publish an npm package too. Give people the choice. Registry for people who want to own it, package for people who want updates.&lt;/p&gt;

&lt;p&gt;I thought about it. I decided not to, at least for now, for a couple of reasons.&lt;/p&gt;

&lt;p&gt;The first is focus. I'm one person. Maintaining a package means thinking about public APIs, semver, breaking changes, backwards compatibility, peer dependency ranges, and all the props that I'd need to expose to make it configurable. That's a whole different job. The registry lets me keep each component as a plain, readable file, which is the thing I actually want to be good at.&lt;/p&gt;

&lt;p&gt;The second is that a package would quietly change how I design the components. The minute something's a package, I'd start adding props for everything, because people can't edit the source. The files would get longer and more abstract. I'd be designing for configurability instead of for how it feels. I think the components would get worse.&lt;/p&gt;

&lt;p&gt;The third is honesty about what these are. They're not infrastructure. They're starting points with good taste baked in. A package says "trust me, don't look inside." A registry says "here's my best version, now make it yours." That second one is the actual relationship I want with people who use this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes people make after copying
&lt;/h2&gt;

&lt;p&gt;Since the code is in your hands, here are the things I see go wrong most often. None of these are your fault exactly, they're just the sharp edges of owning the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing &lt;code&gt;"use client"&lt;/code&gt;.&lt;/strong&gt; The components use state, effects, and Motion, so they're client components. The file starts with &lt;code&gt;"use client"&lt;/code&gt;. If you delete it, or move the logic into a server component, you'll get errors in the Next.js App Router. Keep it. If you want a server rendered wrapper, put that in a separate file that imports the client component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path aliases that don't match.&lt;/strong&gt; The components import &lt;code&gt;cn&lt;/code&gt; from &lt;code&gt;@/lib/utils&lt;/code&gt;, which is the standard shadcn setup. If your project uses a different alias, the CLI usually handles it based on your &lt;code&gt;components.json&lt;/code&gt;, but if you copy paste manually from the docs page instead of using the CLI, you might need to fix the import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overwriting your own changes.&lt;/strong&gt; If you customized a component and then re-add it from the registry without committing first, you can lose your edits. The CLI asks before overwriting, but it's easy to hit yes on autopilot. Commit first. Always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting the "boring" lines.&lt;/strong&gt; Some lines look unnecessary but aren't. In Discrete Tabs, the &lt;code&gt;isLoaded&lt;/code&gt; state is what stops the label from doing its entrance animation on first render. The &lt;code&gt;willChange: "transform"&lt;/code&gt; styles help the browser prepare for the motion. The inline border radius helps Motion keep corners looking right while the pill resizes. If you delete these, it still works, it just gets a little worse in ways that are hard to spot. Read before you cut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customizing in five places at once.&lt;/strong&gt; People open the file and change the spring, the colors, the padding, the font, and the icons all in one go. Then something feels off and they don't know which change caused it. Change one thing, look at it, then change the next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting people who prefer reduced motion.&lt;/strong&gt; Some users have reduced motion turned on at the OS level. Motion has tools for respecting that, like the &lt;code&gt;useReducedMotion&lt;/code&gt; hook and &lt;code&gt;MotionConfig&lt;/code&gt; with &lt;code&gt;reducedMotion="user"&lt;/code&gt;. Since the component is yours, you can decide how to handle it in your app. A common approach is wrapping your app in &lt;code&gt;MotionConfig&lt;/code&gt; so transforms get toned down for people who asked for that. It's a few lines and it's worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The package mindset vs the owner mindset
&lt;/h2&gt;

&lt;p&gt;There's a mental shift that happens when people start using copy model components, and I think it's the most important part of this whole thing.&lt;/p&gt;

&lt;p&gt;In the package mindset, a component is something you consume. You read the docs, you find the props, you use it as intended. If it doesn't do what you want, you either live with it or you file an issue and wait. The component belongs to someone else and you're a guest.&lt;/p&gt;

&lt;p&gt;In the owner mindset, a component is something you start from. You read the code, you understand what each part does, and you make it fit. If it doesn't do what you want, you change it. The component belongs to you and the original author is more like someone who gave you a head start.&lt;/p&gt;

&lt;p&gt;A lot of developers, especially newer ones, are really trained into the package mindset. I was too. It feels wrong to edit something that came from somewhere else, like you're voiding a warranty. There's no warranty. MIT literally says so. Edit it.&lt;/p&gt;

&lt;p&gt;Once that clicks, the copy model gets a lot more fun. You stop thinking "what can this component do" and start thinking "what do I want my product to do." The component is just the fastest way to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  MIT, sponsors, and staying honest
&lt;/h2&gt;

&lt;p&gt;useLayouts is MIT licensed and free. You can use it in personal projects, client work, commercial products, whatever. Keep the license notice around, and that's basically it.&lt;/p&gt;

&lt;p&gt;I chose MIT because it's the only license that actually matches the copy model. If I'm telling you the file is yours, the license needs to back that up. Anything more restrictive would make "it's yours" a lie.&lt;/p&gt;

&lt;p&gt;The project runs on my time, some sponsors, and the support from being in the Vercel Open Source Program Winter 2026 cohort. That program gave the project credits and support from Vercel, which matters more than you might think for a registry. Every time someone runs the CLI, it fetches a JSON file from uselayouts.com. Every docs page has live previews. All of that is hosted, and having Vercel back it means I don't stress about traffic spikes when someone with a big audience shares a component.&lt;/p&gt;

&lt;p&gt;If your company gets real value out of useLayouts, sponsoring is how you keep it free for the solo devs and students who can't pay. If you're building something small, just use it. That's how I want it to work. No pro tier. No locked components. No upsell inside the file.&lt;/p&gt;

&lt;p&gt;And just to clear it up since people mix it up all the time: useLayouts is not UI Layouts. Different creators, different projects. UI Layouts is at ui-layouts.com. Both happen to be in the Vercel OSS program, which hasn't helped with the confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people usually change in other components
&lt;/h2&gt;

&lt;p&gt;Discrete Tabs is the easy example, but the same idea applies everywhere. Here's what I'd expect you to touch first in a few other components, based on what I end up changing myself when I drop them into real projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status Button&lt;/strong&gt; (it's &lt;code&gt;save-button&lt;/code&gt; in the registry). The states are the obvious thing. My demo walks through a simple idle, working, done flow, but your app might need an error state that actually means something, or a state that says "queued" instead of "working." The other big edit is wiring it to your real async call instead of a simulated delay. And timing. How long the success state sticks around before resetting is a product decision, not a library decision. A settings page can show "Saved" for a while. A chat input probably shouldn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete Button.&lt;/strong&gt; Copy, first. The words on a destructive action matter a lot and they should sound like your product. Then the countdown. The stock version flips into a cancel state with a countdown, so you get a few seconds to back out before anything happens. How long that window should be is a product call. Deleting an account deserves a long one. Archiving a row in a list probably wants a short one. The colors and timing live in a config object at the top of the file, so this is a quick edit. Since it's your file, you can also make it conditional, like skipping the countdown for things you can undo anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Morphing Input.&lt;/strong&gt; Usually the content that it morphs between. It's built to show how an input can change shape instead of being replaced, and what it changes into depends entirely on your feature. Search, inline add, a quick compose box. You'll also probably want to connect it to your form library if you use one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing Card.&lt;/strong&gt; Almost everything content wise. Plan names, prices, features, the highlighted plan. The layout is a starting point, but pricing pages are very brand specific, so I'd expect you to change spacing and typography more here than anywhere else. The interaction details are the part to keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-Step Form.&lt;/strong&gt; The steps, obviously, and validation. My version shows the flow and the transitions between steps. Your version needs real fields, real validation rules, and probably a real submit. The motion between steps is the part that makes it feel like progress instead of paperwork, so I'd leave that mostly alone and focus your edits on the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bento Card.&lt;/strong&gt; Grid sizing. Bento layouts live or die by how the cards are sized relative to each other, and that depends on your content. Change the spans until the most important thing is the biggest thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smooth Dropdown and Bottom Menu.&lt;/strong&gt; Items, icons, and what happens on select. These are navigation pieces, so they need to plug into your routing. Same idea as making Discrete Tabs controlled.&lt;/p&gt;

&lt;p&gt;Notice a pattern? In almost every case, the first edits are about content and wiring, not motion. The motion is the part that took me hours to get right, and it's the part you probably shouldn't need to touch much. The content is the part that's always specific to your product, and it's the part a package would make hardest to change. That's the whole case for the copy model in one observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick checklist
&lt;/h2&gt;

&lt;p&gt;If you're about to add your first useLayouts component, here's the short version of this whole post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the registry to &lt;code&gt;components.json&lt;/code&gt; once.&lt;/li&gt;
&lt;li&gt;Read the source on the component's docs page before you add it.&lt;/li&gt;
&lt;li&gt;Run the add command.&lt;/li&gt;
&lt;li&gt;Commit the pristine file.&lt;/li&gt;
&lt;li&gt;Make the edits that matter for your product. Copy, icons, colors, maybe the spring.&lt;/li&gt;
&lt;li&gt;Commit your edits separately.&lt;/li&gt;
&lt;li&gt;Ship it.&lt;/li&gt;
&lt;li&gt;Come back later if you want to tune more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the loop. Copy, customize, ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions I get about this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't copying code an anti pattern? DRY and all that?&lt;/strong&gt;&lt;br&gt;
DRY is about not repeating logic inside your own codebase. Copying a component into your project once, and then using it everywhere from that one file, is totally DRY. It's not different from writing the component yourself, you just didn't have to start from zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if you find a security issue in a component?&lt;/strong&gt;&lt;br&gt;
These are UI components, mostly state and animation, so the surface area is small. But if something important needed fixing, I'd announce it on the repo and the site so people know to update their copies. The diff workflow above makes that easy to apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I copy paste from the docs page instead of using the CLI?&lt;/strong&gt;&lt;br&gt;
Yeah, the source is right there. The CLI is just more convenient because it handles dependencies and file placement for you. If you copy manually, make sure you install the dependencies listed for that component and fix any import paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I publish my modified version?&lt;/strong&gt;&lt;br&gt;
It's MIT, so yes, as long as you keep the license notice. I'd love it if you mentioned where it started, but the license only asks for the notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will you ever break my app with an update?&lt;/strong&gt;&lt;br&gt;
No. I literally can't. Your copy only changes when you change it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work with pnpm, yarn, bun?&lt;/strong&gt;&lt;br&gt;
All of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm dlx shadcn@latest add @uselayouts/discrete-tabs
yarn dlx shadcn@latest add @uselayouts/discrete-tabs
bunx &lt;span class="nt"&gt;--bun&lt;/span&gt; shadcn@latest add @uselayouts/discrete-tabs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Can I add a few at once?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest add @uselayouts/delete-button @uselayouts/save-button
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Go own something
&lt;/h2&gt;

&lt;p&gt;If you've been living in package land and fighting overrides, try this once. Pick one component where your current UI feels a little dead. A tab bar, a delete action, a save button. Add the useLayouts version, commit it, change three things, ship it.&lt;/p&gt;

&lt;p&gt;I think you'll feel the difference not just in the UI, but in how you work. You stop asking a library for permission and you just build.&lt;/p&gt;

&lt;p&gt;Browse the components at uselayouts.com, read the intro in the docs, and if it ends up being useful, a star on GitHub genuinely helps. The repo's at hundreds of stars and climbing, and every one of them helps someone else find it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://uselayouts.com/" rel="noopener noreferrer"&gt;https://uselayouts.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://uselayouts.com/docs/introduction" rel="noopener noreferrer"&gt;https://uselayouts.com/docs/introduction&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/iurvish/uselayouts" rel="noopener noreferrer"&gt;https://github.com/iurvish/uselayouts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

&lt;p&gt;Urvish (@0xUrvish)&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
