CSS Multi Step Form

Radio inputs control which step panel is visible, with no JavaScript and no page reload.

Published May 22, 2026 Intermediate 7 min read

A CSS multi step form splits a long form across several panels and moves between them without a script or a page load. Three radio inputs sitting outside the form hold the current step, and :checked combined with the general sibling combinator decides which panel is displayed. The Next and Back buttons are <label> elements pointing at the radio for the step they lead to, so a click on one changes the checked radio and the stylesheet does the rest.

It is the same state holding trick behind custom radio buttons and a star rating, scaled up to whole sections of a page. Two more versions follow: panels that slide sideways rather than swapping instantly, and a version where the steps are reachable from the keyboard, which the first one is not.

Move between steps

1. Your Info 2. Details 3. Done

Your Info

Details

All done!

✓ Form submitted, zero JavaScript used!

HTML

<input type="radio" name="step" id="step1" class="msf-radio" checked>
<input type="radio" name="step" id="step2" class="msf-radio">
<input type="radio" name="step" id="step3" class="msf-radio">

<div class="msf-wrap">
  <div class="msf-progress">
    <span class="msf-step-label">1. Your Info</span>
    <span class="msf-step-label">2. Details</span>
    <span class="msf-step-label">3. Done</span>
  </div>
  <div class="msf-body">
    <div class="msf-panel msf-panel-1">
      <h3>Your Info</h3>
      <input type="text" placeholder="Full name">
      <input type="email" placeholder="Email address">
      <div class="msf-actions">
        <label for="step2" class="msf-btn">Next →</label>
      </div>
    </div>
    <div class="msf-panel msf-panel-2">
      <h3>Details</h3>
      <input type="text" placeholder="Company (optional)">
      <input type="tel" placeholder="Phone number">
      <div class="msf-actions">
        <label for="step1" class="msf-btn msf-btn-back">← Back</label>
        <label for="step3" class="msf-btn">Next →</label>
      </div>
    </div>
    <div class="msf-panel msf-panel-3">
      <h3>All done!</h3>
      <p class="msf-success">✓ Form submitted, zero JavaScript used!</p>
      <div class="msf-actions">
        <label for="step1" class="msf-btn">Start Over</label>
      </div>
    </div>
  </div>
</div>

CSS

.msf-radio {
  display: none;
}

.msf-wrap {
  max-width: 400px;
  margin: 0 auto;
  background: #141415;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
  overflow: hidden;
}

.msf-progress {
  display: flex;
  background: #1c1c1e;
  border-bottom: 1px solid #2a2a2d;
}

.msf-step-label {
  flex: 1;
  padding: .6rem;
  text-align: center;
  font-size: .75rem;
  color: #88888f;
  font-weight: 500;
  position: relative;
}

.msf-body {
  padding: 1.5rem;
}

.msf-panel {
  display: none;
  flex-direction: column;
  gap: .75rem;
}

.msf-panel h3 {
  font-size: 1.1rem;
  margin-bottom: .25rem;
}

.msf-panel input[type="text"],
.msf-panel input[type="email"],
.msf-panel input[type="tel"] {
  width: 100%;
  padding: .65rem .9rem;
  background: #1c1c1e;
  border: 1px solid #2a2a2d;
  border-radius: 8px;
  color: #f0f0f0;
  font-family: system-ui, sans-serif;
  font-size: .95rem;
  outline: none;
  transition: border-color .2s;
}

.msf-panel input:focus {
  border-color: #b8ff57;
}

.msf-actions {
  display: flex;
  gap: .5rem;
  margin-top: .5rem;
}

.msf-btn {
  display: inline-block;
  padding: .6rem 1.2rem;
  background: #b8ff57;
  color: #0c0c0d;
  border-radius: 8px;
  font-weight: 600;
  font-size: .9rem;
  cursor: pointer;
  text-align: center;
  user-select: none;
  transition: opacity .15s;
}

.msf-btn:hover {
  opacity: .85;
}

.msf-btn-back {
  background: #1c1c1e;
  color: #f0f0f0;
  border: 1px solid #2a2a2d;
}

.msf-success {
  text-align: center;
  padding: 1rem 0;
  color: #57d9a3;
  font-size: 1rem;
}

/* Show only the panel whose radio is checked */
#step1:checked ~ .msf-wrap .msf-panel-1 { display: flex; }
#step2:checked ~ .msf-wrap .msf-panel-2 { display: flex; }
#step3:checked ~ .msf-wrap .msf-panel-3 { display: flex; }

/* Highlight the matching step label */
#step1:checked ~ .msf-wrap .msf-step-label:nth-child(1),
#step2:checked ~ .msf-wrap .msf-step-label:nth-child(2),
#step3:checked ~ .msf-wrap .msf-step-label:nth-child(3) {
  color: #b8ff57;
  border-bottom: 2px solid #b8ff57;
  margin-bottom: -1px;
}

Other ways to build it

Panels that slide instead of swapping

display has no intermediate values, so a transition on it does nothing and the panels change instantly. Laying every panel out side by side inside a track and moving the track with transform: translateX() gives the browser something it can interpolate. The catch is that a panel moved off screen is still laid out and still focusable, so Tab walks into fields nobody can see. visibility: hidden on the inactive panels fixes that, with a transition delay so the outgoing panel stays visible for the length of the slide.

Shipping

Payment

HTML

<div class="msf2">
  <input type="radio" name="slide" id="slide-1" class="msf2-radio" checked>
  <input type="radio" name="slide" id="slide-2" class="msf2-radio">
  <div class="msf2-wrap">
    <div class="msf2-track">
      <div class="msf2-panel">... <label for="slide-2">Next</label></div>
      <div class="msf2-panel">... <label for="slide-1">Back</label></div>
    </div>
  </div>
</div>

CSS

.msf2-wrap {
  overflow: hidden;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
  background: #141415;
}

.msf2-track {
  display: flex;
  width: 200%;
  transition: transform .35s ease;
}

.msf2-panel {
  flex: 0 0 50%;
  padding: 1.5rem;
  /* off-screen panels must leave the tab order, and the delay keeps
     the outgoing one visible for the length of the slide */
  visibility: hidden;
  transition: visibility 0s linear .35s;
}

#slide-2:checked ~ .msf2-wrap .msf2-track {
  transform: translateX(-50%);
}

#slide-1:checked ~ .msf2-wrap .msf2-panel:nth-child(1),
#slide-2:checked ~ .msf2-wrap .msf2-panel:nth-child(2) {
  visibility: visible;
  transition-delay: 0s;
}

@media (prefers-reduced-motion: reduce) {
  .msf2-track { transition: none; }
}

Steps reachable from the keyboard

The version at the top of this page cannot be operated without a mouse. The radios are removed with display: none, and a <label> is not focusable, so Tab reaches the text fields and then stops. Clipping the radios instead leaves them in the tab order as an ordinary radio group: Tab moves into it once, the arrow keys move between steps, and the panel changes as they go. The progress bar entries are the labels here, and the focus ring is forwarded onto the current one. Tab into the strip and press the arrow keys.

Account

Address

Review

Everything looks right

HTML

<div class="msf3">
  <input type="radio" name="multi-step-form-kb" id="kb-1" class="msf3-radio" checked>
  <input type="radio" name="multi-step-form-kb" id="kb-2" class="msf3-radio">
  <input type="radio" name="multi-step-form-kb" id="kb-3" class="msf3-radio">
  <div class="msf3-wrap">
    <div class="msf3-progress">
      <label for="kb-1" class="msf3-step">1. Account</label>
      <label for="kb-2" class="msf3-step">2. Address</label>
      <label for="kb-3" class="msf3-step">3. Review</label>
    </div>
    <div class="msf3-body">
      <div class="msf3-panel">
        <h3>Account</h3>
        <input type="email" placeholder="Email address">
      </div>
      <div class="msf3-panel">
        <h3>Address</h3>
        <input type="text" placeholder="Postcode">
      </div>
      <div class="msf3-panel">
        <h3>Review</h3>
        <p class="msf-success">Everything looks right</p>
      </div>
    </div>
  </div>
</div>

CSS

/* clipped, not display:none, so the group keeps its tab stop */
.msf3-radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.msf3-step {
  flex: 1;
  padding: .6rem;
  text-align: center;
  font-size: .75rem;
  color: #88888f;
  cursor: pointer;
}

.msf3-panel {
  display: none;
}

#kb-1:checked ~ .msf3-wrap .msf3-panel:nth-child(1),
#kb-2:checked ~ .msf3-wrap .msf3-panel:nth-child(2),
#kb-3:checked ~ .msf3-wrap .msf3-panel:nth-child(3) {
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

#kb-1:checked ~ .msf3-wrap .msf3-step:nth-child(1),
#kb-2:checked ~ .msf3-wrap .msf3-step:nth-child(2),
#kb-3:checked ~ .msf3-wrap .msf3-step:nth-child(3) {
  color: #b8ff57;
  border-bottom: 2px solid #b8ff57;
}

/* the ring goes on the step label, because the radio is 1px wide */
#kb-1:focus-visible ~ .msf3-wrap .msf3-step:nth-child(1),
#kb-2:focus-visible ~ .msf3-wrap .msf3-step:nth-child(2),
#kb-3:focus-visible ~ .msf3-wrap .msf3-step:nth-child(3) {
  outline: 2px solid #b8ff57;
  outline-offset: -2px;
}

How it works

Hidden radio inputs act as state holders. Each radio's id corresponds to a step. CSS uses the :checked pseudo-class combined with the general sibling combinator ~ to show only the matching .msf-panel. Navigation buttons are <label> elements pointing to the next step's radio input, so clicking one changes the checked state and CSS handles the rest.

The three radios have to be written before .msf-wrap and at the same level as it. #step2:checked ~ .msf-wrap .msf-panel-2 reads as: find a checked radio with that id, look forward through its siblings for .msf-wrap, then descend to the panel. Sibling combinators never look backwards, so a radio placed inside the wrapper, or after it, breaks the whole thing and every panel stays hidden.

Radios rather than checkboxes, because they share a name and the browser guarantees only one is checked. That means no rule has to unset the previous step. Checking step three automatically unchecks step two, which is what makes the panel swap a single state change rather than a pair of them.

A <label> is what makes a button out of a plain element here. Clicking a label toggles the input it points at with for, and the target does not have to be anywhere near it in the document. That is how a button drawn at the bottom of panel one can change a radio sitting above the entire wrapper. The label needs cursor: pointer to read as a button, since it is not one.

Panels are hidden with display: none and shown with display: flex, which is a hard swap that cannot animate. display is not an interpolatable property, so a transition on it does nothing. Sliding between steps means keeping every panel laid out and moving a track with transform instead, which is what the first variant below does.

The step indicator is driven by :nth-child() rather than by a class on each label. #step2:checked ~ .msf-wrap .msf-step-label:nth-child(2) picks the second label in the progress bar. It keeps the markup free of state classes, and it is the reason the labels have to stay in the same order as the radios.

CSS properties used

:checked
Reads the current step from whichever radio is selected. The browser keeps exactly one checked per name.
~ (general sibling)
Reaches forward from the checked radio to the wrapper. This is why the radios must be written before the wrapper.
display
none and flex swap the panels. Not animatable, which is why the sliding variant uses a transform instead.
:nth-child()
Selects the matching step in the progress bar by position, so no state class has to be written into the markup.
transform
translateX moves a track holding all the panels side by side, which is the animatable alternative to swapping display.
visibility
hidden takes a slid-away panel out of the tab order, which transform alone does not do.

Browser support

FeatureChromeFirefoxSafariEdge
:checked43.53.212
flexbox21286.112
transition455.112
transform43.53.112
:focus-visible86415.486

Figures come from Can I Use, with :checked, the general sibling combinator and :nth-child() all covered by the CSS3 selectors entry. Nothing on this page needs a recent browser. The one caveat is not support but persistence: the current step lives in the DOM, so a refresh or a back button returns to whichever radio carries the checked attribute in the markup.

Accessibility notes

The main demo has a real problem: display: none on the radios takes them out of the tab order, and a <label> is not focusable on its own. That leaves the Next and Back buttons reachable by mouse only. A keyboard user can Tab into the text fields of the current step and then has no way to advance. The third demo on this page fixes it by clipping the radios instead of removing them, which puts the step control back in the tab order as an ordinary radio group with arrow key navigation.

Hidden panels behave differently depending on how they are hidden. display: none removes their fields from the tab order, which is what you want. A panel moved off screen with transform is still laid out and still focusable, so Tab walks into fields nobody can see. The sliding variant handles that with visibility: hidden on the inactive panels and a transition delay so the panel stays visible while it is sliding away.

Nothing announces that the step changed. A screen reader user clicking Next gets no notification, because CSS cannot move focus or fire a live region update. The honest position is that this pattern suits short, low stakes flows. For a checkout or an application form, moving focus to the new panel's heading is necessary, and that needs a line of script.

Give each step a heading and keep the progress indicator visible. The demo shows all three step names at once with the current one marked, which is what tells somebody how much is left. A form that shows only the current step number leaves the reader guessing.

Label buttons need a visible focus state once they are made focusable. In the keyboard version the ring is forwarded from the clipped radio to the step label with #id:focus-visible ~ .msf3-wrap .msf3-step, because the input carrying focus is a single pixel and its own outline would be invisible.

What you can build with it

  • Short sign up flows. Two or three steps where splitting the fields makes the form look shorter than it is.
  • Onboarding walkthroughs. A sequence of explanatory panels with Next and Back, where nothing is being submitted at all.
  • Surveys and quizzes. One question per panel, with the radios doing double duty as both the answer and the step tracker.
  • Product configurators. Size, then color, then quantity, with a review panel at the end summarising the choices.
  • Checkout stages on a static site. Shipping, payment and review as separate panels of one form, so a single submission still posts everything.

Mistakes worth avoiding

  • Putting the radios inside the wrapper. The sibling combinator cannot reach them from there, so every panel stays hidden and the form looks empty.
  • Hiding the radios with display: none. Nothing in the step navigation is focusable afterwards, and the form becomes unusable without a mouse.
  • Transitioning display. It is not an animatable property, so the panels still swap instantly no matter what duration is set.
  • Using checkboxes instead of radios. Two steps can then be checked at once and both panels show, stacked on top of each other.
  • Sliding panels with transform and leaving them focusable. Tab moves into fields that are off screen, and the page scrolls sideways chasing a field nobody can see.

Frequently asked questions

Does a CSS multi step form actually submit?
Yes, as one form. The panels are only visual, so every field in every step is part of the same submission whether or not the reader can see it at the time. Put the <form> around the wrapper and give each input a name.
Can I validate each step before moving on?
Not with CSS alone. form:has(:invalid) can gray out the Next button in the same way described on input validation, but it cannot stop the label from switching the radio, because a label click is not something a stylesheet can cancel. Real per step gating needs script.
Why will my step panels not animate?
Because display cannot be transitioned. The property has no intermediate values for the browser to interpolate. Lay every panel out side by side in a track and move the track with transform: translateX() instead, which is what the sliding variant on this page does.
How do I remember which step someone was on?
You cannot, in CSS. The state lives in the DOM and resets on every page load to whichever radio has the checked attribute. Persisting it means storing the step and restoring it with script, or giving each step its own URL.
Is a multi step form better than one long form?
It depends on the form. Splitting reduces how much a reader sees at once, which helps on a phone and on anything over about ten fields. It also hides the total length, which some people find worse rather than better, so keep the progress indicator visible.