Let's build a multi-step form that works without js. Something that looks like this:
There are 3 steps, each step can contain one or more fields (not important). First step points towards the second step, the second step allows to go to the first step or to the next (also last) step. The third step will allow to go to previous step or to submit the form.
Something to keep in mind:
Whole component consist of 2 parts - scrollable part (let's call it a view
) and a static part (let's call it nav
).
The blue area represents the view
and the white area represents the nav
:
And each step consists of:
- title
- fields
- actions (links or buttons in the
nav
)
Title
and fields
should be rendered in the scrollable part, while actions
should be rendered in nav
.
Seems easy enough.
The markup I came up with is simple:
<article>
<h2>Title</h2>
<fieldset>
<legend>Field 1</legend>
<label>
<span>Option 1</span>
<input type="radio" name="service" value="1" />
</label>
<label>
<span>Option 2</span>
<input type="radio" name="service" value="2" />
</label>
</fieldset>
<a>prev</a>
<a>next</a>
</article>
And the whole form would be:
<form>
<ol>
<li>
<article>...</article>
</li>
<li>
<article>...</article>
</li>
<li>
<article>...</article>
</li>
</ol>
</form>
We have one form
, which is an ordered list with 3 elements.
Each element is a step.
If you look at the markup you may ask - but how we will be able to make actions
(aka links or buttons) to be rendered staticly, so they won't be scrolled with the main content.
Lets try to solve that problem.
We dont need to start with article components, lets start with basic markup:
<form>
<ol></ol>
</form>
We will make form
a grid element - it contains 2 rows, the first one would take all the available space, while the second one will have height of 3em.
form {
block-size: 100%;
overflow: hidden;
display: grid;
grid-template-rows: minmax(0, 1fr) 2rem;
}
Since ol
is the only child of form
, it will take the first row slot. But we can still hard-code its boundaries:
ol {
grid-column: 1/-1;
grid-row: 1/2;
list-style: none;
padding: 0;
margin: 0;
background-color: rgba(0, 0, 255, 0.5);
}
Lets highlight our second slot:
form::before {
content: "";
grid-column: 1/-1;
grid-row: 2/3;
background-color: rgba(0, 0, 0, 0.2);
}
So we got ourselves a pretty masic layout:
Lets move to the next stage - we need to render all 3 steps.
We update our markup to:
<ol>
<li>
<article>
<h2>Step 1</h2>
<fieldset>
<legend>Select a service</legend>
<label>
<span>Option 1</span>
<input type="radio" name="service" value="1" />
</label>
<label>
<span>Option 2</span>
<input type="radio" name="service" value="2" />
</label>
</fieldset>
<a>next</a>
</article>
</li>
<li>
<article>
<h2>Step 2</h2>
<fieldset>
<legend>Select preferences</legend>
<label>
<span>Pref 1</span>
<input type="checkbox" name="pref1" />
</label>
<label>
<span>Pref 2</span>
<input type="checkbox" name="pref2" />
</label>
</fieldset>
<a>prev</a>
<a>next</a>
</article>
</li>
<li>
<article>
<h2>Step 3</h2>
<fieldset>
<legend>Contact information</legend>
<label>
<span>Name</span>
<input type="text" name="name" />
</label>
<label>
<span>Email</span>
<input type="email" name="email" />
</label>
</fieldset>
<a>prev</a>
</article>
</li>
</ol>
So now it looks like this:
The problem is - since ol
is a grid element, it renders one child under another, but we want them to align with x-axis
, not y
.
We also want to remove scrollbar (so users wont be able to scroll between steps), and we want each step to take 100% width of a parent (ol
).
We can do this by adding those styles to ol
:
ol {
/* ... */
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
max-inline-size: 100%;
block-size: 100%;
overflow: hidden;
}
So our steps are rendered one after another, just like we wanted. But we need some way to navigate between them
And since we aim to do this without js, our options are limited.
Likely, a
tag can help us to achieve that. It is called an anchor
, and href
means http reference
. We can use what is called anchor link
, sounds kinds confusing, since a
is already an anchor.
Anyway, anchor link
is a link that points to the element on the same page via its id
. So if there is an element with id
, for example <h1 id="payment">Payment</h2>
, than <a href="#payment">
will actually point to that element and after click the page will be scrolled so our <h1>
will be visible.
Lets create some id's and pass them:
<form>
<ol>
<li>
<article>
<h2 id="step1">Step 1</h2>
<fieldset>...</fieldset>
<a href="#step2">next</a>
</article>
</li>
<li>
<article>
<h2 id="step2">Step 2</h2>
<fieldset>...</fieldset>
<a href="#step1">prev</a>
<a href="#step3">next</a>
</article>
</li>
<li>
<article>
<h2 id="step3">Step 3</h2>
<fieldset>...</fieldset>
<a href="#step2">prev</a>
</article>
</li>
</ol>
</form>
It works, but there is no scroll animation.
scrollIntoView
actually is much better.
Luckyly, we can achieve the same results via css:
ol {
...
scroll-behavior: smooth;
}
Okay, cool. Now we are getting somewhere.
Now we need to render our actions
inside that gray area, but how do we do that?
What if we add update the form
with:
form {
...
position: relative;
}
and change our links to:
a {
position: absolute;
inset: 0;
background: #8100ff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 1ch;
text-decoration: none;
font-size: 1em;
}
Oh no, now our link took whole page:
But we can assign it to a grid cell. Since form
is a grid, we can add:
a {
...
grid-row: 2/3;
grid-column: 1/-1;
}
A little bit better, scroll animation works, links are rendered outside view
, links are static, but they overlap with each other:
Lets make a room for 2 button, so since they are grid-elements of form
, we need to add columns to form:
form {
grid-template: minmax(0, 1fr) 3em / repeat(2, minmax(0, 1fr));
}
We use the short syntax that combines rows and columns declarations, but it is the same as:
form {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr) 3em;
}
Now we need to assign links to the right cells.
So on the first step - there is only one next action
, and it should be grid-column: 2/3;
, last step also has only one link and it is a prev action
, so it should be grid-column: 1/2;
, and in any other case - first link should take the first cell, while the last link takes the second cell.
It is kinda tricky, but i guess we can go with something like this:
/* if there is only one link or if there are more than one - take the first column */
ol li a:only-of-type,
ol li a:not(:only-of-type):nth-of-type(1) {
grid-column: 1/2;
grid-row: 2/3;
}
/* second link will take the second column */
ol li a:nth-of-type(2) {
grid-column: 2/3;
grid-row: 2/3;
}
But still, it renders all links in the nav
, and we want actions only from the current step.
Well, turns out there is a :target
pseudoselector that allows us to add some conditional rendering.
Lets try to render actions only from current step. We will use :has()
selector, which doesnt work in mozilla firefox yet, but overall browser support is good:
/* if some step is active and it is not the first step - hide first step */
ol:has(li:not(:first-of-type) h2::target) li:first-of-type a {
display: none;
}
/* render first step links if the first step is active or if there are no active steps, aka initial state */
ol:not(:has(h2:target)) li:first-of-type a,
ol:has(li:first-of-type h2:target) li:first-of-type a {
display: flex;
grid-row: 2/3;
grid-column: 2/3;
}
/* hide links from non-active step */
h2:not(:target) ~ a {
display: none;
}
/* show links from active step */
h2:target ~ a {
display: flex;
}
/* if there is only one link or if there are more than one - take the first column */
ol li a:only-of-type,
ol li a:not(:only-of-type):nth-of-type(1) {
grid-row: 2/3;
grid-column: 1/2;
}
/* second link will take the second column */
ol li a:nth-of-type(2) {
grid-row: 2/3;
grid-column: 2/3;
}
No it works, we have smooth animations, links are static and conditionally rendered.
But there is no animation for links, since we hide them via display: none
;
Lets change display: none
to opacity: 0; pointer-events: none;
and display: flex
to opacity: 1; pointer-events: auto;
Probably there is some better way to do that and make the link more accessible, but it will do for now:
a {
/* ... */
transition: opacity 0.314s ease-in;
}
ol:has(li:not(:first-of-type) h2::target) li:first-of-type a {
opacity: 0;
pointer-events: none;
}
ol:not(:has(h2:target)) li:first-of-type a,
ol:has(li:first-of-type h2:target) li:first-of-type a {
opacity: 1;
pointer-events: auto;
grid-row: 2/3;
grid-column: 2/3;
}
h2:not(:target) ~ a {
opacity: 0;
pointer-events: none;
}
h2:target ~ a {
opacity: 1;
pointer-events: auto;
}
You can animate it however you want, it is entirely up to you.
There is one more trick.
We defined grid with 2 rows - minmax(0, 1fr)
and 3em
.
So what if we want to change the nav's
font-size?
Well, the trick is to create 2 custom css properties - nav-font-size
and nav-block-gap
, and use it for our grid:
form {
--_nav-font-size: 2em;
--_block-gap: 2em;
grid-template: minmax(0, 1fr) calc(var(--_nav-font-size) + 2 * var(--_block-gap)) / repeat(2, minmax(0, 1fr));
}
a {
font-size: var(--_nav-font-size);
}
This will effectively allow us to calculate the height of nav
by font-size, padding-top and padding-bottom.
So what we have learned:
- anchor links allow us to scroll to element without js
- css has smooth scrolling
- elements can be rendered outside of parent via nont-static position and behave like a grid-items
:target
allows us to conditionally apply styles:has()
allows us to conditionall apply styles
The combination of those features allowed us to build multi-step form that works without js. You can aslo build pure css carousel using the same technique.