Archiwum kategorii: CSS

Thank You (2024 Edition)

Post pobrano z: Thank You (2024 Edition)

I’ll be honest: writing this post feels like a chore some years. Rounding up and reflecting on what’s happened throughout the year is somewhat obligatory for a site like this, especially when it’s a tradition that goes back as far as 2007. “Hey, look at all the cool things we did!”

This year is different. Much different. I’m more thankful this time around because, last year, I didn’t even get to write this post. At this time last year, I was a full-time student bent on earning a master’s degree while doing part-time contract work.

But now that I’m back, writing this feels so, so, so good. There’s a lot more gusto going into my writing when I say: thank you so very much! It’s because of you and your support for this site that I’m back at my regular job. I’d be remiss if I didn’t say that, so please accept my sincerest gratitude and appreciation. Thank you!

Let’s tie a bow on this year and round up what happened around here in 2024.

Overall traffic

Is it worth saying anything about traffic? This site’s pageviews had been trending down since 2020 as it has for just about any blog about front-end dev, but it absolutely cratered when the site was on pause for over a year. Things began moving again in late May, but it was probably closer to mid-June when the engine fully turned over and we resumed regular publishing.

And, yes. With regular publishing came a fresh influx of pageviews. Funny how much difference it makes just turning on the lights.

All said and done, we had 26 million unique pageviews in 2024. That’s exactly what we had in 2023 as traffic went into a tailspin, so I call it a win that we stopped the bleeding and broke even this year.

Publishing

A little bit of history when it comes to how many articles we publish each year:

  • 2020: 1,183 articles
  • 2021: 890 articles (site acquired by DigitalOcean)
  • 2022: 390 articles
  • 2023: 0 articles (site paused)
  • 2024: 153 articles (site resumed in late June)

Going from 0 articles to 153 (including this one) in six months was no small task. I was the only writer on the team until about October. There are only three of us right now; even then, we’re all extremely part-time workers. Between us and 19 guest authors, I’d say that we outperformed expectations as far as quantity goes — but I’m even more proud of the effort and quality that goes into each one. It’s easy to imagine publishing upwards of 400 articles in 2025 if we maintain the momentum.

Case in point: we published a whopping three guides in six months:

That might not sound like a lot, so I’ll put it in context. We published just one guide in 2022 and our goal was to write three in all of 2021. We got three this year alone, and they’re all just plain great. I visit Juan’s Anchor Positioning guide as much as — if not more than — I do the ol’ Flexbox and Grid guides.

On top of that, we garnered 34 new additions to the CSS-Tricks Almanac! That includes all of the features for Anchor Positioning and View Transitions, as well as other new features like @starting-style. And the reason spent so much time in the Almanac is because we made some significant…

Site updates

This is where the bulk of the year was spent, so let’s break things out into digestible chunks.

Almanac

Comparing the old Almanac page header with the new one containing links to new sections.

We refreshed the entire thing! It used to be just selectors and properties, but now we can write about everything from at-rules and functions to pseudos and everything in between. We still need a lot of help in there, so maybe consider guesting writing with us. 😉

Table of Contents

Showing the table of contents widget in the right sidebar of an article.

We’ve been embedding anchor links to section headings in articles for several years, but it required using a WordPress block and it was fairly limiting as far as placement and customization. Now we generate those links automatically and include a conditional that allows us to toggle it on and off for specific articles. I’m working on an article about how it came together that we’ll publish after the holiday break.

Notes

The main Notes screen with the first three notecards showing in a row.

There’s a new section where we take notes on what other people are writing about and share our takeaways with you. The motivation was to lower the barrier to writing more freely. Technical writing takes a lot of care and planning that’s at odds with openly learning and sharing. This way, we have a central spot where you can see what we’re learning and join us along the way — such as this set of notes I took from Bramus’ amazing free course on scroll-driven animations.

Links

The main Links page with the first two link articles showing in a vertical list that includes information about the link.

This is another area of the site that got a fresh coat of paint. Well, more than paint. It used to be that links were in the same stream as the rest of the articles, tutorials, and guides we publish. Links are meant to be snappy, sharable bits — conversation starters if you will. Breaking them out of the main feed into their own distinguished section helps reduce the noise on this site while giving links a brighter spotlight with a quicker path to get to the original article. Like when there’s a new resource for learning Anchor Positioning, we can shoot that out a lot more easily.

Quick Hits

The main page for CSS-Tricks Quick Hits showing the first two items in a vertical list.

We introduced another new piece of content in the form of brief one-liners that you might typically find us posting on Mastodon or Bluesky. We still post to those platforms but now we can write them here on the site and push them out when needed. There’s a lot more flexibility there, even if we haven’t given it a great deal of love just yet.

Picks

The main CSS-Tricks Picks page showing the first two picks in a vertical stack.

There’s a new feed of the articles we’re reading. It might seem a lot like Links, but the idea is that we can simply “star” something from our RSS reader and it’ll show up in the feed. They’re simply interesting articles that catch our attention that we want to spotlight and share, even if we don’t have any commentary to contribute. This was Chris’ brainchild a few years ago and it feels so good to bring it to fruition. I’ll write something up about it after the break, but you can already head over there.

Baseline Status

Showing the Baseline Status plugin in the WordPress Plugin Directory.

Ooo, this one’s fun! I saw that the Chrome team put out a new web component for embedding web platform browser support information on a page so I set out to make it into a WordPress block we can use throughout the Almanac, which we’re already starting to roll out as content is published or refreshed (such as here in the anchor-name property). I’m still working on a write-up about it, but it’s I’ve already made it available in the WordPress Plugin Directory if you want to grab it for your WordPress site.

Or, here… I can simply drop it in and show you.

Post Slider

Post slider for the latest almanac articles, showing the first five cards in a row.

This was one of the first things I made when re-joining the team. We wanted to surface a greater number of articles on the homepage so that it’s easier to find specific types of content, whether it’s the latest five articles, the 10 most recently updated Almanac items or guides, classic CSS tricks from ages ago… that sort of thing. So, we got away from merely showing the 10 most recent articles and developed a series of post sliders that pull from different areas of the site. Converting our existing post slider component into a WordPress block made it more portable and a heckuva lot easier to update the homepage — and any other page or post where we might need a post slider. In fact, that’s another one I can demo for you right here…

So, yeah. This year was heavier on development than many past years. But everything was done with the mindset of making content easier to find, publish, and share. I hope that this is like a little punch on the gas pedal that accelerates our ability to get fresh content out to you.

2025 Goals

I’m quite reluctant to articulate new goals when there are so many things still in flux, but the planner in me can’t help myself. If I can imagine a day at the end of next year when I’m reflecting on things exactly like this, I’d be happy, nay stoked, if I was able to say we did these things:

  • Publish 1-2 new guides. We already have two in the works! That said, the bar for quality is set very high on these, so it’s still a journey to get from planning to publishing two stellar and chunky guides.
  • Fill in the Almanac. My oh my, there is SO much work to do in this little corner of the site. We’ve only got a few pages in the at-rules and functions sections that we recently created and could use all the help we can get.
  • Restart the newsletter. This is something I’ve been itching to do. I know I miss reading the newsletter (especially when Robin was writing it) and this community feels so much smaller and quieter without it. The last issue went out in December 2022 and it’s high time we get it going again. The nuts and bolts are still in place. All we need is a little extra resourcing and the will to do it, and we’ve got at least half of that covered.
  • More guest authors. I mentioned earlier that we’ve worked with 19 guest authors since June of this year. That’s great but also not nearly enough given that this site thrives on bringing in outside voices that we can all learn from. We were clearly busy with development and all kinds of other site updates but I’d like to re-emphasize our writing program this year, with the highest priority going into making it as smooth as possible to submit ideas, receive timely feedback on them, and get paid for what gets published. There’s a lot of invisible work that goes into that but it’s worth everyone’s while because it’s a win-win-win-win (authors win, readers win, CSS-Tricks wins, and DigitalOcean wins).

Here’s to 2025!

Thank you. That’s the most important thing I want to say. And special thanks to Juan Diego Rodriguez and Ryan Trimble. You may not know it, but they joined the team this Fall and have been so gosh-dang incredibly helpful. I wish every team had a Juan and Ryan just like I do — we’d all be better for it, that’s for sure. I know I learn a heckuva lot from them and I’m sure you will (or are!) as well.

Juan Diego with a wide smile looking slightly off to the right.

Juan Diego Rodriguez

Ryan Trimble grinning as he looks off to the left.

Ryan Trimble

Give them high-fives when you see them because they deserve it. ✋


Thank You (2024 Edition) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

How to Wait for the sibling-count() and sibling-index() Functions

Post pobrano z: How to Wait for the sibling-count() and sibling-index() Functions

New features don’t just pop up in CSS (but I wish they did). Rather, they go through an extensive process of discussions and considerations, defining, writing, prototyping, testing, shipping handling support, and many more verbs that I can’t even begin to imagine. That process is long, and despite how much I want to get my hands on a new feature, as an everyday developer, I can only wait.

I can, however, control how I wait: do I avoid all possible interfaces or demos that are possible with that one feature? Or do I push the boundaries of CSS and try to do them anyway?

As ambitious and curious developers, many of us choose the latter option. CSS would grow stagnant without that mentality. That’s why, today, I want to look at two upcoming functions: sibling-count() and sibling-index(). We’re waiting for them — and have been for several years — so I’m letting my natural curiosity get the best of me so I can get a feel for what to be excited about. Join me!

The tree-counting functions

At some point, you’ve probably wanted to know the position of an element amongst its siblings or how many children an element has to calculate something in CSS, maybe for some staggering animation in which each element has a longer delay, or perhaps for changing an element’s background-color depending on its number of siblings. This has been a long-awaited deal on my CSS wishlists. Take this CSSWG GitHub Issue from 2017:

Feature request. It would be nice to be able to use the counter() function inside of calc() function. That would enable new possibilities on layouts.

However, counters work using strings, rendering them useless inside a calc() function that deals with numbers. We need a set of similar functions that return as integers the index of an element and the count of siblings. This doesn’t seem too much to ask. We can currently query an element by its tree position using the :nth-child() pseudo-selector (and its variants), not to mention query an element based on how many items it has using the :has() pseudo-selector.

Luckily, this year the CSSWG approved implementing the sibling-count() and sibling-index() functions! And we already have something in the spec written down:

The sibling-count() functional notation represents, as an <integer>, the total number of child elements in the parent of the element on which the notation is used.

The sibling-index() functional notation represents, as an <integer>, the index of the element on which the notation is used among the children of its parent. Like :nth-child(), sibling-index() is 1-indexed.

How much time do we have to wait to use them? Earlier this year Adam Argyle said that “a Chromium engineer mentioned wanting to do it, but we don’t have a flag to try it out with yet. I’ll share when we do!” So, while I am hopeful to get more news in 2025, we probably won’t see them shipped soon. In the meantime, let’s get to what we can do right now!

Rubbing two sticks together

The closest we can get to tree counting functions in terms of syntax and usage is with custom properties. However, the biggest problem is populating them with the correct index and count. The simplest and longest method is hardcoding each using only CSS: we can use the nth-child() selector to give each element its corresponding index:

li:nth-child(1) {
  --sibling-index: 1;
}

li:nth-child(2) {
  --sibling-index: 2;
}

li:nth-child(3) {
  --sibling-index: 3;
}

/* and so on... */

Setting the sibling-count() equivalent has a bit more nuance since we will need to use quantity queries with the :has() selector. A quantity query has the following syntax:

.container:has(> :last-child:nth-child(m)) { }

…where m is the number of elements we want to target. It works by checking if the last element of a container is also the nth element we are targeting; thus it has only that number of elements. You can create your custom quantity queries using this tool by Temani Afif. In this case, our quantity queries would look like the following:

ol:has(> :nth-child(1)) {
  --sibling-count: 1;
}

ol:has(> :last-child:nth-child(2)) {
  --sibling-count: 2;
}

ol:has(> :last-child:nth-child(3)) {
  --sibling-count: 3;
}

/* and so on... */

This example is intentionally light on the number of elements for brevity, but as the list grows it will become unmanageable. Maybe we could use a preprocessor like Sass to write them for us, but we want to focus on a vanilla CSS solution here. For example, the following demo can support up to 12 elements, and you can already see how ugly it gets in the code.

CodePen Embed Fallback

That’s 24 rules to know the index and count of 12 elements for those of you keeping score. It surely feels like we could get that number down to something more manageable, but if we hardcode each index we are bound increase the amount of code we write. The best we can do is rewrite our CSS so we can nest the --sibling-index and --sibling-count properties together. Instead of writing each property by itself:

li:nth-child(2) {
  --sibling-index: 2;
}

ol:has(> :last-child:nth-child(2)) {
  --sibling-count: 2;
}

We could instead nest the --sibling-count rule inside the --sibling-index rule.

li:nth-child(2) {
  --sibling-index: 2;

  ol:has(> &:last-child) {
    --sibling-count: 2;
  }
}

While it may seem wacky to nest a parent inside its children, the following CSS code is completely valid; we are selecting the second li element, and inside, we are selecting an ol element if its second li element is also the last, so the list only has two elements. Which syntax is easier to manage? It’s up to you.

CodePen Embed Fallback

But that’s just a slight improvement. If we had, say, 100 elements we would still need to hardcode the --sibling-index and --sibling-count properties 100 times. Luckily, the following method will increase rules in a logarithmic way, specifically base-2. So instead of writing 100 rules for 100 elements, we will be writing closer to 10 rules for around 100 elements.

Flint and steel

This method was first described by Roman Komarov in October last year, in which he prototypes both tree counting functions and the future random() function. It’s an amazing post, so I strongly encourage you to read it.

This method also uses custom properties, but instead of hardcoding each one, we will be using two custom properties that will build up the --sibling-index property for each element. Just to be consistent with Roman’s post, we will call them --si1 and --si2, both starting at 0:

li {
  --si1: 0;
  --si2: 0;
}

The real --sibling-index will be constructed using both properties and a factor (F) that represents an integer greater or equal to 2 that tells us how many elements we can select according to the formula sqrt(F) - 1. So…

  • For a factor of 2, we can select 3 elements.
  • For a factor of 3, we can select 8 elements.
  • For a factor of 5, we can select 24 elements.
  • For a factor of 10, we can select 99 elements.
  • For a factor of 25, we can select 624 elements.

As you can see, increasing the factor by one will give us exponential gains on how many elements we can select. But how does all this translate to CSS?

The first thing to know is that the formula for calculating the --sibling-index property is calc(F * var(--si2) + var(--si1)). If we take a factor of 3, it would look like the following:

li {
  --si1: 0;
  --si2: 0;

  /* factor of 3; it's a harcoded number */
  --sibling-index: calc(3 * var(--si2) + var(--si1));
}

The following selectors may be random but stay with me here. For the --si1 property, we will write rules selecting elements that are multiples of the factor and offset them by one 1 until we reach F - 1, then set --si1 to the offset. This translates to the following CSS:

li:nth-child(Fn + 1) { --si1: 1; }
li:nth-child(Fn + 2) { --si1: 2; }
/* ... */
li:nth-child(Fn+(F-1)) { --si1: (F-1) }

So if our factor is 3, we will write the following rules until we reach F-1, so 2 rules:

li:nth-child(3n + 1) { --si1: 1; }
li:nth-child(3n + 2) { --si1: 2; }

For the --si2 property, we will write rules selecting elements in batches of the factor (so if our factor is 3, we will select 3 elements per rule), going from the last possible index (in this case 8) backward until we simply are unable to select more elements in batches. This is a little more convoluted to write in CSS:

li:nth-child(n + F*1):nth-child(-n + F*1-1){--si2: 1;}
li:nth-child(n + F*2):nth-child(-n + F*2-1){--si2: 2;}
/* ... */
li:nth-child(n+(F*(F-1))):nth-child(-n+(F*F-1)) { --si2: (F-1) }

Again, if our factor is 3, we will write the following two rules:

li:nth-child(n + 3):nth-child(-n + 5) {
  --si2: 1;
}
li:nth-child(n + 6):nth-child(-n + 8) {
  --si2: 2;
}

And that’s it! By only setting those two values for --si1 and --si2 we can count up to 8 total elements. The math behind how it works seems wacky at first, but once you visually get it, it all clicks. I made this interactive demo in which you can see how all elements can be reached using this formula. Hover over the code snippets to see which elements can be selected, and click on each snippet to combine them into a possible index.

CodePen Embed Fallback

If you crank the elements and factor to the max, you can see that we can select 48 elements using only 14 snippets!

Wait, one thing is missing: the sibling-count() function. Luckily, we will be reusing all we have learned from prototyping --sibling-index. We will start with two custom properties: --sc1 and --sc1 at the container, both starting at 0 as well. The formula for calculating --sibling-count is the same.

ol {
  --sc1: 0;
  --sc2: 0;

  /* factor of 3; also a harcoded number */
  --sibling-count: calc(3 * var(--sc2) + var(--sc1));
}

Roman’s post also explains how to write selectors for the --sibling-count property by themselves, but we will use the :has() selection method from our first technique so we don’t have to write extra selectors. We can cram those --sc1 and --sc2 properties into the rules where we defined the sibling-index() properties:

/* --si1 and --sc1 */
li:nth-child(3n + 1) {
  --si1: 1;

  ol:has(> &:last-child) {
    --sc1: 1;
  }
}

li:nth-child(3n + 2) {
  --si1: 2;

  ol:has(> &:last-child) {
    --sc1: 2;
  }
}

/* --si2 and --sc2 */
li:nth-child(n + 3):nth-child(-n + 5) {
  --si2: 1;

  ol:has(> &:last-child) {
    --sc2: 1;
  }
}

li:nth-child(n + 6):nth-child(-n + 8) {
  --si2: 2;

  ol:has(> &:last-child) {
    --sc2: 2;
  }
}

This is using a factor of 3, so we can count up to eight elements with only four rules. The following example has a factor of 7, so we can count up to 48 elements with only 14 rules.

CodePen Embed Fallback

This method is great, but may not be the best fit for everyone due to the almost magical way of how it works, or simply because you don’t find it aesthetically pleasing. While for avid hands lighting a fire with flint and steel is a breeze, many won’t get their fire started.

Using a flamethrower

For this method, we will use once again custom properties to mimic the tree counting functions, and what’s best, we will write less than 20 lines of code to count up to infinity—or I guess to 1.7976931348623157e+308, which is the double precision floating point limit!

We will be using the Mutation Observer API, so of course it takes JavaScript. I know that’s like admitting defeat for many, but I disagree. If the JavaScript method is simpler (which it is, by far, in this case), then it’s the most appropriate choice. Just as a side note, if performance is your main worry, stick to hard-coding each index in CSS or HTML.

First, we will grab our container from the DOM:

const elements = document.querySelector("ol");

Then we’ll create a function that sets the --sibling-index property in each element and the --sibling-count in the container (it will be available to its children due to the cascade). For the --sibling-index, we have to loop through the elements.children, and we can get the --sibling-count from elements.children.length.

const updateCustomProperties = () => {
  let index = 1;

  for (element of elements.children) {
    element.style.setProperty("--sibling-index", index);
    index++;
  }

  elements.style.setProperty("--sibling-count", elements.children.length);
};

Once we have our function, remember to call it once so we have our initial tree counting properties:

updateCustomProperties();

Lastly, the Mutation Observer. We need to initiate a new observer using the MutationObserver constructor. It takes a callback that gets invoked each time the elements change, so we write our updateCustomProperties function. With the resulting observer object, we can call its observe() method which takes two parameters:

  1. the element we want to observe, and
  2. a config object that defines what we want to observe through three boolean properties: attributes, childList, and subtree. In this case, we just want to check for changes in the child list, so we set that one to true:
const observer = new MutationObserver(updateCustomProperties);
const config = {attributes: false, childList: true, subtree: false};
observer.observe(elements, config);

That would be all we need! Using this method we can count many elements, in the following demo I set the max to 100, but it can easily reach tenfold:

CodePen Embed Fallback

So yeah, that’s our flamethrower right there. It definitely gets the fire started, but it’s plenty overkill for the vast majority of use cases. But that’s what we have while we wait for the perfect lighter.

More information and tutorials

Related Issues


How to Wait for the sibling-count() and sibling-index() Functions originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

How to Create Multi-Step Forms With Vanilla JavaScript and CSS

Post pobrano z: How to Create Multi-Step Forms With Vanilla JavaScript and CSS

Multi-step forms are a good choice when your form is large and has many controls. No one wants to scroll through a super-long form on a mobile device. By grouping controls on a screen-by-screen basis, we can improve the experience of filling out long, complex forms.

But when was the last time you developed a multi-step form? Does that even sound fun to you? There’s so much to think about and so many moving pieces that need to be managed that I wouldn’t blame you for resorting to a form library or even some type of form widget that handles it all for you.

But doing it by hand can be a good exercise and a great way to polish the basics. I’ll show you how I built my first multi-step form, and I hope you’ll not only see how approachable it can be but maybe even spot areas to make my work even better.

We’ll walk through the structure together. We’ll build a job application, which I think many of us can relate to these recent days. I’ll scaffold the baseline HTML, CSS, and JavaScript first, and then we’ll look at considerations for accessibility and validation.

I’ve created a GitHub repo for the final code if you want to refer to it along the way.

The structure of a multi-step form

Our job application form has four sections, the last of which is a summary view, where we show the user all their answers before they submit them. To achieve this, we divide the HTML into four sections, each identified with an ID, and add navigation at the bottom of the page. I’ll give you that baseline HTML in the next section.

Navigating the user to move through sections means we’ll also include a visual indicator for what step they are at and how many steps are left. This indicator can be a simple dynamic text that updates according to the active step or a fancier progress bar type of indicator. We’ll do the former to keep things simple and focused on the multi-step nature of the form.,

The structure and basic styles

We’ll focus more on the logic, but I will provide the code snippets and a link to the complete code at the end.

Let’s start by creating a folder to hold our pages. Then, create an index.html file and paste the following into it:

Open HTML
<form id="myForm">
  <section class="group-one" id="one">
    <div class="form-group">
      <div class="form-control">
        <label for="name">Name <span style="color: red;">*</span></label>
        <input type="text" id="name" name="name" placeholder="Enter your name">
      </div>

      <div class="form-control">
        <label for="idNum">ID number <span style="color: red;">*</span></label>
        <input type="number" id="idNum" name="idNum" placeholder="Enter your ID number">
      </div>
    </div>

    <div class="form-group">
      <div class="form-control">
        <label for="email">Email <span style="color: red;">*</span></label>
        <input type="email" id="email" name="email" placeholder="Enter your email">
      </div>

      <div class="form-control">
        <label for="birthdate">Date of Birth <span style="color: red;">*</span></label>
        <input type="date" id="birthdate" name="birthdate" max="2006-10-01" min="1924-01-01">
      </div>
    </div>
  </section>

  <section class="group-two" id="two">
    <div class="form-control">
      <label for="document">Upload CV <span style="color: red;">*</span></label>
      <input type="file" name="document" id="document">
    </div>

    <div class="form-control">
      <label for="department">Department <span style="color: red;">*</span></label>
      <select id="department" name="department">
        <option value="">Select a department</option>
        <option value="hr">Human Resources</option>
        <option value="it">Information Technology</option>
        <option value="finance">Finance</option>
      </select>
    </div>
  </section>

  <section class="group-three" id="three">
    <div class="form-control">
      <label for="skills">Skills (Optional)</label>
      <textarea id="skills" name="skills" rows="4" placeholder="Enter your skills"></textarea>
    </div>

    <div class="form-control">
      <input type="checkbox" name="terms" id="terms">
      <label for="terms">I agree to the terms and conditions <span style="color: red;">*</span></label>
    </div>

    <button id="btn" type="submit">Confirm and Submit</button>
  </section>
  
  <div class="arrows">
    <button type="button" id="navLeft">Previous</button>
    <span id="stepInfo"></span>
    <button type="button" id="navRight">Next</button>
  </div>
</form>

<script src="script.js"></script>

Looking at the code, you can see three sections and the navigation group. The sections contain form inputs and no native form validation. This is to give us better control of displaying the error messages because native form validation is only triggered when you click the submit button.

Next, create a styles.css file and paste this into it:

Open base styles
:root {
  --primary-color: #8c852a;
  --secondary-color: #858034;
}

body {
  font-family: sans-serif;
  line-height: 1.4;
  margin: 0 auto;
  padding: 20px;
  background-color: #f4f4f4;
  max-width: 600px;
}

h1 {
  text-align: center;
}

form {
  background: #fff;
  padding: 40px;
  border-radius: 5px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
}

.form-group {
  display: flex;
  gap: 7%;

}

.form-group > div {
  width: 100%;
}

input:not([type="checkbox"]),
select,
textarea {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.form-control {
  margin-bottom: 15px;
}

button {
  display: block;
  width: 100%;
  padding: 10px;
  color: white;
  background-color: var(--primary-color);
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;

}

button:hover {
  background-color: var(--secondary-color);
}

.group-two, .group-three {
  display: none;
}

.arrows {
  display: flex;
  justify-content: space-between
  align-items: center;
  margin-top: 10px;
}

#navLeft, #navRight {
  width: fit-content;
}

@media screen and (max-width: 600px) {
  .form-group {
    flex-direction: column;
  }
}

Open up the HTML file in the browser, and you should get something like the two-column layout in the following screenshot, complete with the current page indicator and navigation.

Page one of a three-page form with four fields in a two-column layout

Adding functionality with vanilla JavaScript

Now, create a script.js file in the same directory as the HTML and CSS files and paste the following JavaScript into it:

Open base scripts
const stepInfo = document.getElementById("stepInfo");
const navLeft = document.getElementById("navLeft");
const navRight = document.getElementById("navRight");
const form = document.getElementById("myForm");
const formSteps = ["one", "two", "three"];

let currentStep = 0;

function updateStepVisibility() {
  formSteps.forEach((step) => {
    document.getElementById(step).style.display = "none";
  });

  document.getElementById(formSteps[currentStep]).style.display = "block";
  stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;
  navLeft.style.display = currentStep === 0 ? "none" : "block";
  navRight.style.display =
  currentStep === formSteps.length - 1 ? "none" : "block";
}

document.addEventListener("DOMContentLoaded", () => {
  navLeft.style.display = "none";
  updateStepVisibility();
  navRight.addEventListener("click", () => {
    if (currentStep < formSteps.length - 1) {
      currentStep++;
      updateStepVisibility();
    }
  });

  navLeft.addEventListener("click", () => {
    if (currentStep > 0) {
      currentStep--;
      updateStepVisibility();
    }
  });
});

This script defines a method that shows and hides the section depending on the formStep values that correspond to the IDs of the form sections. It updates stepInfo with the current active section of the form. This dynamic text acts as a progress indicator to the user.

It then adds logic that waits for the page to load and click events to the navigation buttons to enable cycling through the different form sections. If you refresh your page, you will see that the multi-step form works as expected.

Multi-step form navigation

Let’s dive deeper into what the Javascript code above is doing. In the updateStepVisibility() function, we first hide all the sections to have a clean slate:

formSteps.forEach((step) => {
  document.getElementById(step).style.display = "none";
});

Then, we show the currently active section:

document.getElementById(formSteps[currentStep]).style.display = "block";`

Next, we update the text that indicators progress through the form:

stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;

Finally, we hide the Previous button if we are at the first step and hide the Next button if we are at the last section:

navLeft.style.display = currentStep === 0 ? "none" : "block";
navRight.style.display = currentStep === formSteps.length - 1 ? "none" : "block";

Let’s look at what happens when the page loads. We first hide the Previous button as the form loads on the first section:

document.addEventListener("DOMContentLoaded", () => {
navLeft.style.display = "none";
updateStepVisibility();

Then we grab the Next button and add a click event that conditionally increments the current step count and then calls the updateStepVisibility() function, which then updates the new section to be displayed:

navRight.addEventListener("click", () => {
  if (currentStep < formSteps.length - 1) {
    currentStep++;
    updateStepVisibility();
  }
});

Finally, we grab the Previous button and do the same thing but in reverse. Here, we are conditionally decrementing the step count and calling the updateStepVisibility():

navLeft.addEventListener("click", () => {
  if (currentStep > 0) {
    currentStep--;
    updateStepVisibility();
  }
});

Handling errors

Have you ever spent a good 10+ minutes filling out a form only to submit it and get vague errors telling you to correct this and that? I prefer it when a form tells me right away that something’s amiss so that I can correct it before I ever get to the Submit button. That’s what we’ll do in our form.

Our principle is to clearly indicate which controls have errors and give meaningful error messages. Clear errors as the user takes necessary actions. Let’s add some validation to our form. First, let’s grab the necessary input elements and add this to the existing ones:

const nameInput = document.getElementById("name");
const idNumInput = document.getElementById("idNum");
const emailInput = document.getElementById("email");
const birthdateInput = document.getElementById("birthdate")
const documentInput = document.getElementById("document");
const departmentInput = document.getElementById("department");
const termsCheckbox = document.getElementById("terms");
const skillsInput = document.getElementById("skills");

Then, add a function to validate the steps:

Open validation script
function validateStep(step) {
  let isValid = true;
  
  if (step === 0) {
    if (nameInput.value.trim() === "") 
    showError(nameInput, "Name is required");
    isValid = false;
  }

  if (idNumInput.value.trim() === "") {
    showError(idNumInput, "ID number is required");
    isValid = false;
  }

  if (emailInput.value.trim() === "" || !emailInput.validity.valid) {
    showError(emailInput, "A valid email is required");
    isValid = false;
  }

  if (birthdateInput.value === "") {
    showError(birthdateInput, "Date of birth is required");
    isValid = false;
  }
  
  else if (step === 1) {

    if (!documentInput.files[0]) {
      showError(documentInput, "CV is required");
      isValid = false;
    }

    if (departmentInput.value === "") {
      showError(departmentInput, "Department selection is required");
      isValid = false;
    }
  } else if (step === 2) {

    if (!termsCheckbox.checked) {
      showError(termsCheckbox, "You must accept the terms and conditions");
      isValid = false;
    }
  }

  return isValid;
}

Here, we check if each required input has some value and if the email input has a valid input. Then, we set the isValid boolean accordingly. We also call a showError() function, which we haven’t defined yet.

Paste this code above the validateStep() function:

function showError(input, message) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.add("error");
  errorSpan.textContent = message;
}

Now, add the following styles to the stylesheet:

Open validation styles
input:focus, select:focus, textarea:focus {
  outline: .5px solid var(--primary-color);
}

input.error, select.error, textarea.error {
  outline: .5px solid red;
}

.error-message {
  font-size: x-small;
  color: red;
  display: block;
  margin-top: 2px;
}

.arrows {
  color: var(--primary-color);
  font-size: 18px;
  font-weight: 900;

}

#navLeft, #navRight {
  display: flex;
  align-items: center;
  gap: 10px;
}

#stepInfo {
  color: var(--primary-color);
}

If you refresh the form, you will see that the buttons do not take you to the next section till the inputs are considered valid:

A personnel registration form with fields for name, ID number, email, and date of birth, all marked as required. Step 1 of 3 with a "Next" button.

Finally, we want to add real-time error handling so that the errors go away when the user starts inputting the correct information. Add this function below the validateStep() function:

Open real-time validation script
function setupRealtimeValidation() {
  nameInput.addEventListener("input", () => {
    if (nameInput.value.trim() !== "") clearError(nameInput);
  });

  idNumInput.addEventListener("input", () => {
    if (idNumInput.value.trim() !== "") clearError(idNumInput);
  });
  
  emailInput.addEventListener("input", () => {
    if (emailInput.validity.valid) clearError(emailInput);
  });
  
  birthdateInput.addEventListener("change", () => {
    if (birthdateInput.value !== "") clearError(birthdateInput);
  });
  
  documentInput.addEventListener("change", () => {
    if (documentInput.files[0]) clearError(documentInput);
  });
  
  departmentInput.addEventListener("change", () => {
    if (departmentInput.value !== "") clearError(departmentInput);
  });
  
  termsCheckbox.addEventListener("change", () => {
    if (termsCheckbox.checked) clearError(termsCheckbox);
  });
}

This function clears the errors if the input is no longer invalid by listening to input and change events then calling a function to clear the errors. Paste the clearError() function below the showError() one:

function clearError(input) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.remove("error");
  errorSpan.textContent = "";
}

And now the errors clear when the user types in the correct value:

A personnel registration form with fields for name, email, ID number, and date of birth. Error messages indicate missing or invalid input. A "Step 1 of 3" label and "Next" button are shown.

The multi-step form now handles errors gracefully. If you do decide to keep the errors till the end of the form, then at the very least, jump the user back to the erroring form control and show some indication of how many errors they need to fix.

Handling form submission

In a multi-step form, it is valuable to show the user a summary of all their answers at the end before they submit and to offer them an option to edit their answers if necessary. The person can’t see the previous steps without navigating backward, so showing a summary at the last step gives assurance and a chance to correct any mistakes.

Let’s add a fourth section to the markup to hold this summary view and move the submit button within it. Paste this just below the third section in index.html:

Open HTML
<section class="group-four" id="four">
  <div class="summary-section">
    <p>Name: </p>
    <p id="name-val"></p>
    <button type="button" class="edit-btn" id="name-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>ID Number: </p>
    <p id="id-val"></p>
    <button type="button" class="edit-btn" id="id-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>Email: </p>
    <p id="email-val"></p>
    <button type="button" class="edit-btn" id="email-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>Date of Birth: </p>
    <p id="bd-val"></p>
    <button type="button" class="edit-btn" id="bd-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>CV/Resume: </p>
    <p id="cv-val"></p>
    <button type="button" class="edit-btn" id="cv-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
    
  <div class="summary-section">
    <p>Department: </p>
    <p id="dept-val"></p>
    <button type="button" class="edit-btn" id="dept-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
    
  <div class="summary-section">
    <p>Skills: </p>
    <p id="skills-val"></p>
    <button type="button" class="edit-btn" id="skills-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
  
  <button id="btn" type="submit">Confirm and Submit</button>
</section>

Then update the formStep in your Javascript to read:

const formSteps = ["one", "two", "three", "four"];

Finally, add the following classes to styles.css:

.summary-section {
  display: flex;
  align-items: center;
  gap: 10px;
}

.summary-section p:first-child {
  width: 30%;
  flex-shrink: 0;
  border-right: 1px solid var(--secondary-color);
}

.summary-section p:nth-child(2) {
  width: 45%;
  flex-shrink: 0;
  padding-left: 10px;
}

.edit-btn {
  width: 25%;
  margin-left: auto;
  background-color: transparent;
  color: var(--primary-color);
  border: .7px solid var(--primary-color);
  border-radius: 5px;
  padding: 5px;
}

.edit-btn:hover {
  border: 2px solid var(--primary-color);
  font-weight: bolder;
  background-color: transparent;
}

Now, add the following to the top of the script.js file where the other consts are:

const nameVal = document.getElementById("name-val");
const idVal = document.getElementById("id-val");
const emailVal = document.getElementById("email-val");
const bdVal = document.getElementById("bd-val")
const cvVal = document.getElementById("cv-val");
const deptVal = document.getElementById("dept-val");
const skillsVal = document.getElementById("skills-val");
const editButtons = 
  "name-edit": 0,
  "id-edit": 0,
  "email-edit": 0,
  "bd-edit": 0,
  "cv-edit": 1,
  "dept-edit": 1,
  "skills-edit": 2
};

Then add this function in scripts.js:

function updateSummaryValues() {
  nameVal.textContent = nameInput.value;
  idVal.textContent = idNumInput.value;
  emailVal.textContent = emailInput.value;
  bdVal.textContent = birthdateInput.value;

  const fileName = documentInput.files[0]?.name;
  if (fileName) 
  const extension = fileName.split(".").pop();
  const baseName = fileName.split(".")[0];
  const truncatedName = baseName.length > 10 ? baseName.substring(0, 10) + "..." : baseName;
  cvVal.textContent = `${truncatedName}.${extension}`;
  } else {
    cvVal.textContent = "No file selected";
  }

  deptVal.textContent = departmentInput.value;
  skillsVal.textContent = skillsInput.value || "No skills submitted";
}

This dynamically inserts the input values into the summary section of the form, truncates the file names, and offers a fallback text for the input that was not required.

Then update the updateStepVisibility() function to call the new function:

function updateStepVisibility() {
  formSteps.forEach((step) => {
    document.getElementById(step).style.display = "none";
  });

  document.getElementById(formSteps[currentStep]).style.display = "block";
  stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;
  if (currentStep === 3) {
    updateSummaryValues();
  }

  navLeft.style.display = currentStep === 0 ? "none" : "block";
  navRight.style.display = currentStep === formSteps.length - 1 ? "none" : "block";
}

Finally, add this to the DOMContentLoaded event listener:

Object.keys(editButtons).forEach((buttonId) => {
  const button = document.getElementById(buttonId);
  button.addEventListener("click", (e) => {
    currentStep = editButtons[buttonId];
    updateStepVisibility();
  });
});

Running the form, you should see that the summary section shows all the inputted values and allows the user to edit any before submitting the information:

Personnel registration form displaying personal details with options to edit each field, a "Confirm and Submit" button, and navigation for previous steps.

And now, we can submit our form:

form.addEventListener("submit", (e) => {
  e.preventDefault();

  if (validateStep(2)) {
    alert("Form submitted successfully!");
    form.reset();
    currentFormStep = 0;
    updateStepVisibility();
}
});

Our multi-step form now allows the user to edit and see all the information they provide before submitting it.

Accessibility tips

Making multi-step forms accessible starts with the basics: using semantic HTML. This is half the battle. It is closely followed by using appropriate form labels.

Other ways to make forms more accessible include giving enough room to elements that must be clicked on small screens and giving meaningful descriptions to the form navigation and progress indicators.

Offering feedback to the user is an important part of it; it’s not great to auto-dismiss user feedback after a certain amount of time but to allow the user to dismiss it themselves. Paying attention to contrast and font choice is important, too, as they both affect how readable your form is.

Let’s make the following adjustments to the markup for more technical accessibility:

  1. Add aria-required="true" to all inputs except the skills one. This lets screen readers know the fields are required without relying on native validation.
  2. Add role="alert" to the error spans. This helps screen readers know to give it importance when the input is in an error state.
  3. Add role="status" aria-live="polite" to the .stepInfo. This will help screen readers understand that the step info keeps tabs on a state, and the aria-live being set to polite indicates that should the value change, it does not need to immediately announce it.

In the script file, replace the showError() and clearError() functions with the following:

function showError(input, message) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.add("error");
  input.setAttribute("aria-invalid", "true");
  input.setAttribute("aria-describedby", errorSpan.id);
  errorSpan.textContent = message;
  }

  function clearError(input) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.remove("error");
  input.removeAttribute("aria-invalid");
  input.removeAttribute("aria-describedby");
  errorSpan.textContent = "";
}

Here, we programmatically add and remove attributes that explicitly tie the input with its error span and show that it is in an invalid state.

Finally, let’s add focus on the first input of every section; add the following code to the end of the updateStepVisibility() function:

const currentStepElement = document.getElementById(formSteps[currentStep]);
const firstInput = currentStepElement.querySelector(
  "input, select, textarea"
);

if (firstInput) {
  firstInput.focus();
}

And with that, the multi-step form is much more accessible.

Conclusion

There we go, a four-part multi-step form for a job application! As I said at the top of this article, there’s a lot to juggle — so much so that I wouldn’t fault you for looking for an out-of-the-box solution.

But if you have to hand-roll a multi-step form, hopefully now you see it’s not a death sentence. There’s a happy path that gets you there, complete with navigation and validation, without turning away from good, accessible practices.

And this is just how I approached it! Again, I took this on as a personal challenge to see how far I could get, and I’m pretty happy with it. But I’d love to know if you see additional opportunities to make this even more mindful of the user experience and considerate of accessibility.

References

Here are some relevant links I referred to when writing this article:

  1. How to Structure a Web Form (MDN)
  2. Multi-page Forms (W3C.org)
  3. Create accessible forms (A11y Project)

How to Create Multi-Step Forms With Vanilla JavaScript and CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

How to Create Multi-Step Forms With Vanilla JavaScript and CSS

Post pobrano z: How to Create Multi-Step Forms With Vanilla JavaScript and CSS

Multi-step forms are a good choice when your form is large and has many controls. No one wants to scroll through a super-long form on a mobile device. By grouping controls on a screen-by-screen basis, we can improve the experience of filling out long, complex forms.

But when was the last time you developed a multi-step form? Does that even sound fun to you? There’s so much to think about and so many moving pieces that need to be managed that I wouldn’t blame you for resorting to a form library or even some type of form widget that handles it all for you.

But doing it by hand can be a good exercise and a great way to polish the basics. I’ll show you how I built my first multi-step form, and I hope you’ll not only see how approachable it can be but maybe even spot areas to make my work even better.

We’ll walk through the structure together. We’ll build a job application, which I think many of us can relate to these recent days. I’ll scaffold the baseline HTML, CSS, and JavaScript first, and then we’ll look at considerations for accessibility and validation.

I’ve created a GitHub repo for the final code if you want to refer to it along the way.

The structure of a multi-step form

Our job application form has four sections, the last of which is a summary view, where we show the user all their answers before they submit them. To achieve this, we divide the HTML into four sections, each identified with an ID, and add navigation at the bottom of the page. I’ll give you that baseline HTML in the next section.

Navigating the user to move through sections means we’ll also include a visual indicator for what step they are at and how many steps are left. This indicator can be a simple dynamic text that updates according to the active step or a fancier progress bar type of indicator. We’ll do the former to keep things simple and focused on the multi-step nature of the form.,

The structure and basic styles

We’ll focus more on the logic, but I will provide the code snippets and a link to the complete code at the end.

Let’s start by creating a folder to hold our pages. Then, create an index.html file and paste the following into it:

Open HTML
<form id="myForm">
  <section class="group-one" id="one">
    <div class="form-group">
      <div class="form-control">
        <label for="name">Name <span style="color: red;">*</span></label>
        <input type="text" id="name" name="name" placeholder="Enter your name">
      </div>

      <div class="form-control">
        <label for="idNum">ID number <span style="color: red;">*</span></label>
        <input type="number" id="idNum" name="idNum" placeholder="Enter your ID number">
      </div>
    </div>

    <div class="form-group">
      <div class="form-control">
        <label for="email">Email <span style="color: red;">*</span></label>
        <input type="email" id="email" name="email" placeholder="Enter your email">
      </div>

      <div class="form-control">
        <label for="birthdate">Date of Birth <span style="color: red;">*</span></label>
        <input type="date" id="birthdate" name="birthdate" max="2006-10-01" min="1924-01-01">
      </div>
    </div>
  </section>

  <section class="group-two" id="two">
    <div class="form-control">
      <label for="document">Upload CV <span style="color: red;">*</span></label>
      <input type="file" name="document" id="document">
    </div>

    <div class="form-control">
      <label for="department">Department <span style="color: red;">*</span></label>
      <select id="department" name="department">
        <option value="">Select a department</option>
        <option value="hr">Human Resources</option>
        <option value="it">Information Technology</option>
        <option value="finance">Finance</option>
      </select>
    </div>
  </section>

  <section class="group-three" id="three">
    <div class="form-control">
      <label for="skills">Skills (Optional)</label>
      <textarea id="skills" name="skills" rows="4" placeholder="Enter your skills"></textarea>
    </div>

    <div class="form-control">
      <input type="checkbox" name="terms" id="terms">
      <label for="terms">I agree to the terms and conditions <span style="color: red;">*</span></label>
    </div>

    <button id="btn" type="submit">Confirm and Submit</button>
  </section>
  
  <div class="arrows">
    <button type="button" id="navLeft">Previous</button>
    <span id="stepInfo"></span>
    <button type="button" id="navRight">Next</button>
  </div>
</form>

<script src="script.js"></script>

Looking at the code, you can see three sections and the navigation group. The sections contain form inputs and no native form validation. This is to give us better control of displaying the error messages because native form validation is only triggered when you click the submit button.

Next, create a styles.css file and paste this into it:

Open base styles
:root {
  --primary-color: #8c852a;
  --secondary-color: #858034;
}

body {
  font-family: sans-serif;
  line-height: 1.4;
  margin: 0 auto;
  padding: 20px;
  background-color: #f4f4f4;
  max-width: 600px;
}

h1 {
  text-align: center;
}

form {
  background: #fff;
  padding: 40px;
  border-radius: 5px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
}

.form-group {
  display: flex;
  gap: 7%;

}

.form-group > div {
  width: 100%;
}

input:not([type="checkbox"]),
select,
textarea {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.form-control {
  margin-bottom: 15px;
}

button {
  display: block;
  width: 100%;
  padding: 10px;
  color: white;
  background-color: var(--primary-color);
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;

}

button:hover {
  background-color: var(--secondary-color);
}

.group-two, .group-three {
  display: none;
}

.arrows {
  display: flex;
  justify-content: space-between
  align-items: center;
  margin-top: 10px;
}

#navLeft, #navRight {
  width: fit-content;
}

@media screen and (max-width: 600px) {
  .form-group {
    flex-direction: column;
  }
}

Open up the HTML file in the browser, and you should get something like the two-column layout in the following screenshot, complete with the current page indicator and navigation.

Page one of a three-page form with four fields in a two-column layout

Adding functionality with vanilla JavaScript

Now, create a script.js file in the same directory as the HTML and CSS files and paste the following JavaScript into it:

Open base scripts
const stepInfo = document.getElementById("stepInfo");
const navLeft = document.getElementById("navLeft");
const navRight = document.getElementById("navRight");
const form = document.getElementById("myForm");
const formSteps = ["one", "two", "three"];

let currentStep = 0;

function updateStepVisibility() {
  formSteps.forEach((step) => {
    document.getElementById(step).style.display = "none";
  });

  document.getElementById(formSteps[currentStep]).style.display = "block";
  stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;
  navLeft.style.display = currentStep === 0 ? "none" : "block";
  navRight.style.display =
  currentStep === formSteps.length - 1 ? "none" : "block";
}

document.addEventListener("DOMContentLoaded", () => {
  navLeft.style.display = "none";
  updateStepVisibility();
  navRight.addEventListener("click", () => {
    if (currentStep < formSteps.length - 1) {
      currentStep++;
      updateStepVisibility();
    }
  });

  navLeft.addEventListener("click", () => {
    if (currentStep > 0) {
      currentStep--;
      updateStepVisibility();
    }
  });
});

This script defines a method that shows and hides the section depending on the formStep values that correspond to the IDs of the form sections. It updates stepInfo with the current active section of the form. This dynamic text acts as a progress indicator to the user.

It then adds logic that waits for the page to load and click events to the navigation buttons to enable cycling through the different form sections. If you refresh your page, you will see that the multi-step form works as expected.

Multi-step form navigation

Let’s dive deeper into what the Javascript code above is doing. In the updateStepVisibility() function, we first hide all the sections to have a clean slate:

formSteps.forEach((step) => {
  document.getElementById(step).style.display = "none";
});

Then, we show the currently active section:

document.getElementById(formSteps[currentStep]).style.display = "block";`

Next, we update the text that indicators progress through the form:

stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;

Finally, we hide the Previous button if we are at the first step and hide the Next button if we are at the last section:

navLeft.style.display = currentStep === 0 ? "none" : "block";
navRight.style.display = currentStep === formSteps.length - 1 ? "none" : "block";

Let’s look at what happens when the page loads. We first hide the Previous button as the form loads on the first section:

document.addEventListener("DOMContentLoaded", () => {
navLeft.style.display = "none";
updateStepVisibility();

Then we grab the Next button and add a click event that conditionally increments the current step count and then calls the updateStepVisibility() function, which then updates the new section to be displayed:

navRight.addEventListener("click", () => {
  if (currentStep < formSteps.length - 1) {
    currentStep++;
    updateStepVisibility();
  }
});

Finally, we grab the Previous button and do the same thing but in reverse. Here, we are conditionally decrementing the step count and calling the updateStepVisibility():

navLeft.addEventListener("click", () => {
  if (currentStep > 0) {
    currentStep--;
    updateStepVisibility();
  }
});

Handling errors

Have you ever spent a good 10+ minutes filling out a form only to submit it and get vague errors telling you to correct this and that? I prefer it when a form tells me right away that something’s amiss so that I can correct it before I ever get to the Submit button. That’s what we’ll do in our form.

Our principle is to clearly indicate which controls have errors and give meaningful error messages. Clear errors as the user takes necessary actions. Let’s add some validation to our form. First, let’s grab the necessary input elements and add this to the existing ones:

const nameInput = document.getElementById("name");
const idNumInput = document.getElementById("idNum");
const emailInput = document.getElementById("email");
const birthdateInput = document.getElementById("birthdate")
const documentInput = document.getElementById("document");
const departmentInput = document.getElementById("department");
const termsCheckbox = document.getElementById("terms");
const skillsInput = document.getElementById("skills");

Then, add a function to validate the steps:

Open validation script
function validateStep(step) {
  let isValid = true;
  
  if (step === 0) {
    if (nameInput.value.trim() === "") 
    showError(nameInput, "Name is required");
    isValid = false;
  }

  if (idNumInput.value.trim() === "") {
    showError(idNumInput, "ID number is required");
    isValid = false;
  }

  if (emailInput.value.trim() === "" || !emailInput.validity.valid) {
    showError(emailInput, "A valid email is required");
    isValid = false;
  }

  if (birthdateInput.value === "") {
    showError(birthdateInput, "Date of birth is required");
    isValid = false;
  }
  
  else if (step === 1) {

    if (!documentInput.files[0]) {
      showError(documentInput, "CV is required");
      isValid = false;
    }

    if (departmentInput.value === "") {
      showError(departmentInput, "Department selection is required");
      isValid = false;
    }
  } else if (step === 2) {

    if (!termsCheckbox.checked) {
      showError(termsCheckbox, "You must accept the terms and conditions");
      isValid = false;
    }
  }

  return isValid;
}

Here, we check if each required input has some value and if the email input has a valid input. Then, we set the isValid boolean accordingly. We also call a showError() function, which we haven’t defined yet.

Paste this code above the validateStep() function:

function showError(input, message) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.add("error");
  errorSpan.textContent = message;
}

Now, add the following styles to the stylesheet:

Open validation styles
input:focus, select:focus, textarea:focus {
  outline: .5px solid var(--primary-color);
}

input.error, select.error, textarea.error {
  outline: .5px solid red;
}

.error-message {
  font-size: x-small;
  color: red;
  display: block;
  margin-top: 2px;
}

.arrows {
  color: var(--primary-color);
  font-size: 18px;
  font-weight: 900;

}

#navLeft, #navRight {
  display: flex;
  align-items: center;
  gap: 10px;
}

#stepInfo {
  color: var(--primary-color);
}

If you refresh the form, you will see that the buttons do not take you to the next section till the inputs are considered valid:

A personnel registration form with fields for name, ID number, email, and date of birth, all marked as required. Step 1 of 3 with a "Next" button.

Finally, we want to add real-time error handling so that the errors go away when the user starts inputting the correct information. Add this function below the validateStep() function:

Open real-time validation script
function setupRealtimeValidation() {
  nameInput.addEventListener("input", () => {
    if (nameInput.value.trim() !== "") clearError(nameInput);
  });

  idNumInput.addEventListener("input", () => {
    if (idNumInput.value.trim() !== "") clearError(idNumInput);
  });
  
  emailInput.addEventListener("input", () => {
    if (emailInput.validity.valid) clearError(emailInput);
  });
  
  birthdateInput.addEventListener("change", () => {
    if (birthdateInput.value !== "") clearError(birthdateInput);
  });
  
  documentInput.addEventListener("change", () => {
    if (documentInput.files[0]) clearError(documentInput);
  });
  
  departmentInput.addEventListener("change", () => {
    if (departmentInput.value !== "") clearError(departmentInput);
  });
  
  termsCheckbox.addEventListener("change", () => {
    if (termsCheckbox.checked) clearError(termsCheckbox);
  });
}

This function clears the errors if the input is no longer invalid by listening to input and change events then calling a function to clear the errors. Paste the clearError() function below the showError() one:

function clearError(input) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.remove("error");
  errorSpan.textContent = "";
}

And now the errors clear when the user types in the correct value:

A personnel registration form with fields for name, email, ID number, and date of birth. Error messages indicate missing or invalid input. A "Step 1 of 3" label and "Next" button are shown.

The multi-step form now handles errors gracefully. If you do decide to keep the errors till the end of the form, then at the very least, jump the user back to the erroring form control and show some indication of how many errors they need to fix.

Handling form submission

In a multi-step form, it is valuable to show the user a summary of all their answers at the end before they submit and to offer them an option to edit their answers if necessary. The person can’t see the previous steps without navigating backward, so showing a summary at the last step gives assurance and a chance to correct any mistakes.

Let’s add a fourth section to the markup to hold this summary view and move the submit button within it. Paste this just below the third section in index.html:

Open HTML
<section class="group-four" id="four">
  <div class="summary-section">
    <p>Name: </p>
    <p id="name-val"></p>
    <button type="button" class="edit-btn" id="name-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>ID Number: </p>
    <p id="id-val"></p>
    <button type="button" class="edit-btn" id="id-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>Email: </p>
    <p id="email-val"></p>
    <button type="button" class="edit-btn" id="email-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>Date of Birth: </p>
    <p id="bd-val"></p>
    <button type="button" class="edit-btn" id="bd-edit">
    <span>✎</span>
    <span>Edit</span>
    </button>
  </div>
  
  <div class="summary-section">
    <p>CV/Resume: </p>
    <p id="cv-val"></p>
    <button type="button" class="edit-btn" id="cv-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
    
  <div class="summary-section">
    <p>Department: </p>
    <p id="dept-val"></p>
    <button type="button" class="edit-btn" id="dept-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
    
  <div class="summary-section">
    <p>Skills: </p>
    <p id="skills-val"></p>
    <button type="button" class="edit-btn" id="skills-edit">
      <span>✎</span>
      <span>Edit</span>
    </button>
  </div>
  
  <button id="btn" type="submit">Confirm and Submit</button>
</section>

Then update the formStep in your Javascript to read:

const formSteps = ["one", "two", "three", "four"];

Finally, add the following classes to styles.css:

.summary-section {
  display: flex;
  align-items: center;
  gap: 10px;
}

.summary-section p:first-child {
  width: 30%;
  flex-shrink: 0;
  border-right: 1px solid var(--secondary-color);
}

.summary-section p:nth-child(2) {
  width: 45%;
  flex-shrink: 0;
  padding-left: 10px;
}

.edit-btn {
  width: 25%;
  margin-left: auto;
  background-color: transparent;
  color: var(--primary-color);
  border: .7px solid var(--primary-color);
  border-radius: 5px;
  padding: 5px;
}

.edit-btn:hover {
  border: 2px solid var(--primary-color);
  font-weight: bolder;
  background-color: transparent;
}

Now, add the following to the top of the script.js file where the other consts are:

const nameVal = document.getElementById("name-val");
const idVal = document.getElementById("id-val");
const emailVal = document.getElementById("email-val");
const bdVal = document.getElementById("bd-val")
const cvVal = document.getElementById("cv-val");
const deptVal = document.getElementById("dept-val");
const skillsVal = document.getElementById("skills-val");
const editButtons = 
  "name-edit": 0,
  "id-edit": 0,
  "email-edit": 0,
  "bd-edit": 0,
  "cv-edit": 1,
  "dept-edit": 1,
  "skills-edit": 2
};

Then add this function in scripts.js:

function updateSummaryValues() {
  nameVal.textContent = nameInput.value;
  idVal.textContent = idNumInput.value;
  emailVal.textContent = emailInput.value;
  bdVal.textContent = birthdateInput.value;

  const fileName = documentInput.files[0]?.name;
  if (fileName) 
  const extension = fileName.split(".").pop();
  const baseName = fileName.split(".")[0];
  const truncatedName = baseName.length > 10 ? baseName.substring(0, 10) + "..." : baseName;
  cvVal.textContent = `${truncatedName}.${extension}`;
  } else {
    cvVal.textContent = "No file selected";
  }

  deptVal.textContent = departmentInput.value;
  skillsVal.textContent = skillsInput.value || "No skills submitted";
}

This dynamically inserts the input values into the summary section of the form, truncates the file names, and offers a fallback text for the input that was not required.

Then update the updateStepVisibility() function to call the new function:

function updateStepVisibility() {
  formSteps.forEach((step) => {
    document.getElementById(step).style.display = "none";
  });

  document.getElementById(formSteps[currentStep]).style.display = "block";
  stepInfo.textContent = `Step ${currentStep + 1} of ${formSteps.length}`;
  if (currentStep === 3) {
    updateSummaryValues();
  }

  navLeft.style.display = currentStep === 0 ? "none" : "block";
  navRight.style.display = currentStep === formSteps.length - 1 ? "none" : "block";
}

Finally, add this to the DOMContentLoaded event listener:

Object.keys(editButtons).forEach((buttonId) => {
  const button = document.getElementById(buttonId);
  button.addEventListener("click", (e) => {
    currentStep = editButtons[buttonId];
    updateStepVisibility();
  });
});

Running the form, you should see that the summary section shows all the inputted values and allows the user to edit any before submitting the information:

Personnel registration form displaying personal details with options to edit each field, a "Confirm and Submit" button, and navigation for previous steps.

And now, we can submit our form:

form.addEventListener("submit", (e) => {
  e.preventDefault();

  if (validateStep(2)) {
    alert("Form submitted successfully!");
    form.reset();
    currentFormStep = 0;
    updateStepVisibility();
}
});

Our multi-step form now allows the user to edit and see all the information they provide before submitting it.

Accessibility tips

Making multi-step forms accessible starts with the basics: using semantic HTML. This is half the battle. It is closely followed by using appropriate form labels.

Other ways to make forms more accessible include giving enough room to elements that must be clicked on small screens and giving meaningful descriptions to the form navigation and progress indicators.

Offering feedback to the user is an important part of it; it’s not great to auto-dismiss user feedback after a certain amount of time but to allow the user to dismiss it themselves. Paying attention to contrast and font choice is important, too, as they both affect how readable your form is.

Let’s make the following adjustments to the markup for more technical accessibility:

  1. Add aria-required="true" to all inputs except the skills one. This lets screen readers know the fields are required without relying on native validation.
  2. Add role="alert" to the error spans. This helps screen readers know to give it importance when the input is in an error state.
  3. Add role="status" aria-live="polite" to the .stepInfo. This will help screen readers understand that the step info keeps tabs on a state, and the aria-live being set to polite indicates that should the value change, it does not need to immediately announce it.

In the script file, replace the showError() and clearError() functions with the following:

function showError(input, message) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.add("error");
  input.setAttribute("aria-invalid", "true");
  input.setAttribute("aria-describedby", errorSpan.id);
  errorSpan.textContent = message;
  }

  function clearError(input) {
  const formControl = input.parentElement;
  const errorSpan = formControl.querySelector(".error-message");
  input.classList.remove("error");
  input.removeAttribute("aria-invalid");
  input.removeAttribute("aria-describedby");
  errorSpan.textContent = "";
}

Here, we programmatically add and remove attributes that explicitly tie the input with its error span and show that it is in an invalid state.

Finally, let’s add focus on the first input of every section; add the following code to the end of the updateStepVisibility() function:

const currentStepElement = document.getElementById(formSteps[currentStep]);
const firstInput = currentStepElement.querySelector(
  "input, select, textarea"
);

if (firstInput) {
  firstInput.focus();
}

And with that, the multi-step form is much more accessible.

Conclusion

There we go, a four-part multi-step form for a job application! As I said at the top of this article, there’s a lot to juggle — so much so that I wouldn’t fault you for looking for an out-of-the-box solution.

But if you have to hand-roll a multi-step form, hopefully now you see it’s not a death sentence. There’s a happy path that gets you there, complete with navigation and validation, without turning away from good, accessible practices.

And this is just how I approached it! Again, I took this on as a personal challenge to see how far I could get, and I’m pretty happy with it. But I’d love to know if you see additional opportunities to make this even more mindful of the user experience and considerate of accessibility.

References

Here are some relevant links I referred to when writing this article:

  1. How to Structure a Web Form (MDN)
  2. Multi-page Forms (W3C.org)
  3. Create accessible forms (A11y Project)

How to Create Multi-Step Forms With Vanilla JavaScript and CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What ELSE is on your CSS wishlist?

Post pobrano z: What ELSE is on your CSS wishlist?

What else do we want or need CSS to do? It’s like being out late at night someplace you shouldn’t be and a stranger in a trenchcoat walks up and whispers in your ear.

“Psst. You wanna buy some async @imports? I’ve got the specificity you want.”

You know you shouldn’t entertain the idea but you do it anyway. All your friends doing Cascade Layers. What are you, a square?

I keep thinking of how amazing it is to write CSS today. There was an email exchange just this morning where I was discussing a bunch of ideas for a persistent set of controls in the UI that would have sounded bonkers even one year ago if it wasn’t for new features, like anchor positioning, scroll timelines, auto-height transitions, and popovers. We’re still in the early days of all these things — among many, many more — and have yet to see all the awesome possibilities come to fruition. Exciting times!

Chris kept a CSS wishlist, going back as far as 2013 and following up on it in 2019. We all have things we’d like to see CSS do and we always will no matter how many sparkly new features we get. Let’s revisit the ones from 2013:

  1. ✅ “I’d like to be able to select an element based on if it contains another particular selector.” Hello, :has()!
  2. ❌ “I’d like to be able to select an element based on the content it contains.”
  3. ❌ “I’d like multiple pseudo-elements.”
  4. ✅ “I’d like to be able to animate/transition something to height: auto;” Yep, we got that!
  5. 🟠 “I’d like things from Sass, like @extend, @mixin, and nesting.” We got the nesting part down with some progress on mixins.
  6. ❌ “I’d like ::nth-letter, ::nth-word, etc.”
  7. ✅ “I’d like all the major browsers to auto-update.” This one was already fulfilled.

So, about a score of 3.5 out of 7. It could very well be that some of these things fell out of favor at some point (haven’t heard any crying for a new pseudo-element since the first wishlist). Chris re-articulated the list this way:

  • Parent queries. As in, selecting an element any-which-way, then selecting the parent of that element. We have some proof it’s possible with :focus-within.
  • Container queries. Select a particular element when the element itself is under certain conditions.
  • Standardized styling of form elements.
  • Has/Contains Selectors.
  • Transitions to auto dimensions.
  • Fixed up handling of viewport units.

And we’ve got the vast majority of those under wraps! We have ways to query parents and containers. We’re exploring stylable selects and field-sizing. We know about :has() and we’re still going gaga over transitions to intrinsic sizes. We’ve openly opined whether there’s too much CSS (there isn’t).

But what else is on your CSS wishlist? Ironically enough, Adam Argyle went through this exercise just this morning and I love the way he’s broken things down into a user-facing wishlist and a developer-facing wishlist. I mean, geez, a CSS carousel? Yes, please! I love his list and all lists like it.

We’ll round things up and put a list together — so let us know!


What ELSE is on your CSS wishlist? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What ELSE is on your CSS wishlist?

Post pobrano z: What ELSE is on your CSS wishlist?

What else do we want or need CSS to do? It’s like being out late at night someplace you shouldn’t be and a stranger in a trenchcoat walks up and whispers in your ear.

“Psst. You wanna buy some async @imports? I’ve got the specificity you want.”

You know you shouldn’t entertain the idea but you do it anyway. All your friends doing Cascade Layers. What are you, a square?

I keep thinking of how amazing it is to write CSS today. There was an email exchange just this morning where I was discussing a bunch of ideas for a persistent set of controls in the UI that would have sounded bonkers even one year ago if it wasn’t for new features, like anchor positioning, scroll timelines, auto-height transitions, and popovers. We’re still in the early days of all these things — among many, many more — and have yet to see all the awesome possibilities come to fruition. Exciting times!

Chris kept a CSS wishlist, going back as far as 2013 and following up on it in 2019. We all have things we’d like to see CSS do and we always will no matter how many sparkly new features we get. Let’s revisit the ones from 2013:

  1. ✅ “I’d like to be able to select an element based on if it contains another particular selector.” Hello, :has()!
  2. ❌ “I’d like to be able to select an element based on the content it contains.”
  3. ❌ “I’d like multiple pseudo-elements.”
  4. ✅ “I’d like to be able to animate/transition something to height: auto;” Yep, we got that!
  5. 🟠 “I’d like things from Sass, like @extend, @mixin, and nesting.” We got the nesting part down with some progress on mixins.
  6. ❌ “I’d like ::nth-letter, ::nth-word, etc.”
  7. ✅ “I’d like all the major browsers to auto-update.” This one was already fulfilled.

So, about a score of 3.5 out of 7. It could very well be that some of these things fell out of favor at some point (haven’t heard any crying for a new pseudo-element since the first wishlist). Chris re-articulated the list this way:

  • Parent queries. As in, selecting an element any-which-way, then selecting the parent of that element. We have some proof it’s possible with :focus-within.
  • Container queries. Select a particular element when the element itself is under certain conditions.
  • Standardized styling of form elements.
  • Has/Contains Selectors.
  • Transitions to auto dimensions.
  • Fixed up handling of viewport units.

And we’ve got the vast majority of those under wraps! We have ways to query parents and containers. We’re exploring stylable selects and field-sizing. We know about :has() and we’re still going gaga over transitions to intrinsic sizes. We’ve openly opined whether there’s too much CSS (there isn’t).

But what else is on your CSS wishlist? Ironically enough, Adam Argyle went through this exercise just this morning and I love the way he’s broken things down into a user-facing wishlist and a developer-facing wishlist. I mean, geez, a CSS carousel? Yes, please! I love his list and all lists like it.

We’ll round things up and put a list together — so let us know!


What ELSE is on your CSS wishlist? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Fluid Superscripts and Subscripts

Post pobrano z: Fluid Superscripts and Subscripts

Superscripts and subscripts are essential elements in academic and scientific content — from citation references to chemical formulas and mathematical expressions. Yet browsers handle these elements with a static approach that can create significant problems: elements become either too small on mobile devices or disproportionately large on desktop displays.

After years of wrestling with superscript and subscript scaling in CSS, I’m proposing a modern solution using fluid calculations. In this article, I’ll show you why the static approach falls short and how we can provide better typography across all viewports while maintaining accessibility. Best of all, this solution requires nothing but clean, pure CSS.

The problem with static scaling

The scaling issue is particularly evident when comparing professional typography with browser defaults. Take this example (adapted from Wikipedia), where the first “2” is professionally designed and included in the glyph set, while the second uses <sub> (top) and <sup> (bottom) elements:

Diagramming the typographic parts and spacing of subscripts and superscripts.

Browsers have historically used font-size: smaller for <sup> and <sub> elements, which translates to roughly 0.83x scaling. While this made sense in the early days of CSS for simple documents, it can create problems in modern responsive designs where font sizes can vary dramatically. This is especially true when using fluid typography, where text sizes can scale smoothly between extremes.

Fluid scaling: A better solution

I’ve developed a solution that scales more naturally across different sizes by combining fixed and proportional units. This approach ensures legibility at small sizes while maintaining proper proportions at larger sizes, eliminating the need for context-specific adjustments.

CodePen Embed Fallback

Here’s how it works:

sup, sub {
  font-size: calc(0.5em + 4px);
  vertical-align: baseline;
  position: relative; 
  top: calc(-0.5 * 0.83 * 2 * (1em - 4px)); 
  /* Simplified top: calc(-0.83em + 3.32px) */
}

sub {
  top: calc(0.25 * 0.83 * 2 * (1em - 4px)); 
  /* Simplified top: calc(0.42em - 1.66px) */
}
  • Natural scaling: The degressive formula ensures that superscripts and subscripts remain proportional at all sizes
  • Baseline alignment: By using vertical-align: baseline and relative positioning, we prevent the elements from affecting line height and it gives us better control over the offset to match your specific needs. You’re probably also wondering where the heck these values come from — I’ll explain in the following.

Breaking down the math

Let’s look at how this works, piece by piece:

Calculating the font size (px)

At small sizes, the fixed 4px component has more impact. At large sizes, the 0.5em proportion becomes dominant. The result is more natural scaling across all sizes.

sup, sub {
  font-size: calc(0.5em + 4px);
  /* ... */
}

sub { 
  /* ... */
}

Calculating the parent font size (em)

Within the <sup> and <sub> elements, we can calculate the parent’s font-size:

sup, sub {
  font-size: calc(0.5em + 4px);
  top: calc(2 * (1em - 4px));
}

sub { 
  top: calc(2 * (1em + 4px));
}

The fluid font size is defined as calc(0.5em + 4px). To compensate for the 0.5em, we first need to solve 0.5em * x = 1em which gives us x = 2. The 1em here represents the font size of the <sup> and <sub> elements themselves. We subtract the 4px fixed component from our current em value before multiplying.

The vertical offset

For the vertical offset, we start with default CSS positioning values and adjust them to work with our fluid scaling:

sup, sub {
  font-size: calc(0.5em + 4px);
  top: calc(-0.5 * 0.83 * 2 * (1em - 4px));
}

sub { 
  top: calc(0.25 * 0.83 * 2 * (1em - 4px));
}

The formula is carefully calibrated to match standard browser positioning:

  • 0.5em (super) and 0.25em (sub) are the default vertical offset values (e.g. used in frameworks like Tailwind CSS and Bootstrap).
  • We multiply by 0.83 to account for the browser’s font-size: smaller scaling factor, which is used per default for superscript and subscript.

This approach ensures that our superscripts and subscripts maintain familiar vertical positions while benefiting from improved fluid scaling. The result matches what users expect from traditional browser rendering but scales more naturally across different font sizes.

Helpful tips

The exact scaling factor font-size: (0.5em + 4px) is based on my analysis of superscript Unicode characters in common fonts. Feel free to adjust these values to match your specific design needs. Here are a few ways how you might want to customize this approach:

For larger scaling:

sup, sub {
  font-size: calc(0.6em + 3px);
  /* adjust offset calculations accordingly */
}

For smaller scaling:

sup, sub {
  font-size: calc(0.4em + 5px);
  /* adjust offset calculations accordingly */
}

For backward compatibility, you might want to wrap all of it in a @supports block:

@supports (font-size: calc(1em + 1px)) {
  sup, sub {
    ...
  }
}

Final demo

I built this small interactive demo to show different fluid scaling options, compare them to the browser’s static scaling, and fine-tune the vertical positioning to see what works best for your use case:

CodePen Embed Fallback

Give it a try in your next project and happy to hear your thoughts!


Fluid Superscripts and Subscripts originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Yet Another Anchor Positioning Quirk

Post pobrano z: Yet Another Anchor Positioning Quirk

I strongly believe Anchor Positioning will go down as one of the greatest additions to CSS. It may not be as game-changing as Flexbox or Grid, but it does fill a positioning gap that has been missing for decades. As awesome as I think it is, CSS Anchor Positioning has a lot of quirks, some of which are the product of its novelty and others due to its unique way of working. Today, I want to bring you yet another Anchor Positioning quirk that has bugged me since I first saw it.

The inception

It all started a month ago when I was reading about what other people have made using Anchor Positioning, specifically this post by Temani Afif about “Anchor Positioning & Scroll-Driven Animations.” I strongly encourage you to read it and find out what caught my eye there. Combining Anchor Positioning and Scroll-Driven Animation, he makes a range slider that changes colors while it progresses.

CodePen Embed Fallback

Amazing by itself, but it’s interesting that he is using two target elements with the same anchor name, each attached to its corresponding anchor, just like magic. If this doesn’t seem as interesting as it looks, we should then briefly recap how Anchor Positioning works.

CSS Anchor Positioning and the anchor-scope property

See our complete CSS Anchor Positioning Guide for a comprehensive deep dive.

Anchor Positioning brings two new concepts to CSS, an anchor element and a target element. The anchor is the element used as a reference for positioning other elements, hence the anchor name. While the target is an absolutely-positioned element placed relative to one or more anchors.

An anchor and a target can be almost every element, so you can think of them as just two div sitting next to each other:

<div class="anchor">anchor</div>
<div class="target">target</div>

To start, we first have to register the anchor element in CSS using the anchor-name property:

.anchor {
  anchor-name: --my-anchor;
}

And the position-anchor property on an absolutely-positioned element attaches it to an anchor of the same name. However, to move the target around the anchor we need the position-area property.

.target {
  position: absolute;
  position-anchor: --my-anchor;
  position-area: top right;
}
CodePen Embed Fallback

This works great, but things get complicated if we change our markup to include more anchors and targets:

<ul>
  <li>
    <div class="anchor">anchor 1</div>
    <div class="target">target 1</div>
  </li>
  <li>
    <div class="anchor">anchor 2</div>
    <div class="target">target 2</div>
  </li>
  <li>
    <div class="anchor">anchor 3</div>
    <div class="target">target 3</div>
  </li>
</ul>

Instead of each target attaching to its closest anchor, they all pile up at the last registered anchor in the DOM.

CodePen Embed Fallback

The anchor-scope property was introduced in Chrome 131 as an answer to this issue. It limits the scope of anchors to a subtree so that each target attaches correctly. However, I don’t want to focus on this property, because what initially caught my attention was that Temani didn’t use it. For some reason, they all attached correctly, again, like magic.

What’s happening?

Targets usually attach to the last anchor on the DOM instead of their closest anchor, but in our first example, we saw two anchors with the same anchor-name and their corresponding targets attached. All this without the anchor-scope property. What’s happening?

Two words: Containing Block.

Something to know about Anchor Positioning is that it relies a lot on how an element’s containing block is built. This isn’t something inherently from Anchor Positioning but from absolute positioning. Absolute elements are positioned relative to their containing block, and inset properties like top: 0px, left: 30px or inset: 1rem are just moving an element around its containing block boundaries, creating what’s called the inset-modified containing block.

An element being shrunk by its inset-modified containing block

A target attached to an anchor isn’t any different, and what the position-area property does under the table is change the target’s inset-modified containing block so it is right next to the anchor.

A target element inset-modified containing block shrunk to be in the top left corner of an anchor

Usually, the containing block of an absolutely-positioned element is the whole viewport, but it can be changed by any ancestor with a position other than static (usually relative). Temani takes advantage of this fact and creates a new containing block for each slider, so they can only be attached to their corresponding anchors. If you snoop around the code, you can find it at the beginning:

label {
  position: relative;
  /* No, It's not useless so don't remove it (or remove it and see what happens) */
}

If we use this tactic on our previous examples, suddenly they are all correctly attached!

CodePen Embed Fallback

Yet another quirk

We didn’t need to use the anchor-scope property to attach each anchor to its respective target, but instead took advantage of how the containing block of absolute elements is computed. However, there is yet another approach, one that doesn’t need any extra bits of code.

This occurred to me when I was also experimenting with Scroll-Driven Animations and Anchor Positioning and trying to attach text-bubble footnotes on the side of a post, like the following:

A blog post body with paragraphs, the paragraphs have footnotes attached on the sides

Logically, each footnote would be a target, but the choice of an anchor is a little more tricky. I initially thought that each paragraph would work as an anchor, but that would mean having more than one anchor with the same anchor-name. The result: all the targets would pile up at the last anchor:

CodePen Embed Fallback

This could be solved using our prior approach of creating a new containing block for each note. However, there is another route we can take, what I call the reductionist method. The problem comes when there is more than one anchor with the same anchor-name, so we will reduce the number of anchors to one, using an element that could work as the common anchor for all targets.

In this case, we just want to position each target on the sides of the post so we can use the entire body of the post as an anchor, and since each target is naturally aligned on the vertical axis, what’s left is to move them along the horizontal axis:

CodePen Embed Fallback

You can better check how it was done on the original post!

Conclusion

The anchor-scope may be the most recent CSS property to be shipped to a browser (so far, just in Chrome 131+), so we can’t expect its support to be something out of this world. And while I would love to use it every now and there, it will remain bound to short demos for a while. This isn’t a reason to limit the use of other Anchor Positioning properties, which are supported in Chrome 125 onwards (and let’s hope in other browsers in the near future), so I hope these little quirks can help you to keep using Anchor Positioning without any fear.


Yet Another Anchor Positioning Quirk originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

CSS Wrapped 2024

Post pobrano z: CSS Wrapped 2024

Join the Chrome DevRel team and a skateboarding Chrome Dino on a journey through the latest CSS launched for Chrome and the web platform in 2024, highlighting 17 new features

That breaks down (approximately) as:

Components

Interactions

Developer experience

Plus:


CSS Wrapped 2024 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

CSSWG Minutes Telecon (2024-12-04): Just Use Grid vs. Display: Masonry

Post pobrano z: CSSWG Minutes Telecon (2024-12-04): Just Use Grid vs. Display: Masonry

The CSS Working Group (CSSWG) meets weekly (or close to it) to discuss and quickly resolve issues from their GitHub that would otherwise be lost in the back-and-forth of forum conversation. While each meeting brings interesting conversation, this past Wednesday (December 4th) was special. The CSSWG met to try and finally squash a debate that has been going on for five years: whether Masonry should be a part of Grid or a separate system.

I’ll try to summarize the current state of the debate, but if you are looking for the long version, I recommend reading CSS Masonry & CSS Grid by Geoff and Choosing a Masonry Syntax in CSS by Miriam Suzanne.

In 2017, it was frequently asked whether Grid could handle masonry layouts; layouts where the columns (or the rows) could hold unevenly sized items without gaps in between. While this is just one of several possibilities with masonry, you can think about the layout popularized by Pinterest:

Pinterest showing a masonry layout

In 2020, Firefox released a prototype in which masonry was integrated into the CSS Grid layout module. The main voice against it was Rachel Andrew, arguing that it should be its own, separate thing. Since then, the debate has escalated with two proposals from Apple and Google, arguing for and against a grid-integrated syntax, respectively.

There were some technical worries against a grid-masonry implementation that were since resolved. What you have to know is this: right now, it’s a matter of syntax. To be specific, which syntax is

a. is easier to learn for authors and

b. how might this decision impact possible future developments in one or both models (or CSS in general).

In the middle, the W3C Technical Architecture Group (TAG) was asked for input on the issue which has prompted an effort to unify the two proposals. Both sides have brought strong arguments to the table over a series of posts, and in the following meeting, they were asked to lay those arguments once again in a presentation, with the hope of reaching a consensus.

Remember that you can subscribe and read the full minutes on W3C.org

The Battle of PowerPoints

Alison Maher representing Google and an advocate of implementing Masonry as a new display value, opened the meeting with a presentation. The main points were:

  1. Several properties behave differently between masonry and grid.
  2. Better defaults when setting display: masonry, something that Rachel Andrew recently argued for.
  3. There was an argument against display: masonry since fallbacks would be more lengthy to implement, whereas in a grid-integrated the fallback to grid is already there. Alison Maher refutes this since “needing one is a temporary problem, so [we] should focus on the future,” and that “authors should make explicit fallback, to avoid surprises.”
  4. “Positioning in masonry is simpler than grid, it’s only placed in 1 axis instead of 2.”
  5. Shorthands are also better: “Grid shorthand is complicated, hard to use. Masonry shorthand is easier because don’t need to remember the order.”
  6. “Placement works differently in grid vs masonry” and “alignment is also very different”
  7. There will be “other changes for submasonry/subgrid that will lead to divergences.”
  8. “Integrating masonry into grid will lead to spec bloat, will be harder to teach, and lead to developer confusion.”

alisonmaher: “Conclusion: masonry should be a separate display type”

Jen Simmons, representing the WebKit team and advocate of the “Just Use Grid” approach followed with another presentation. On this side, the main points were:

  1. Author learning could be skewed since “a new layout type creates a separate tool with separate syntax that’s similar but not the same as what exists […]. They’re familiar but not quite the same”
  2. The Chrome proposal would add around 10 new properties. “We don’t believe there’s a compelling argument to add so many new properties to CSS.”
Table of properties that display: masonry would add. (around 10 new properties)
  1. “Chromium argues that their new syntax is more understandable. We disagree, just use grid-auto-flow“
  2. “When you layout rows in grid, template syntax is a bit different — you stack the template names to physically diagram the names for the rows. Just Use Grid re-uses this syntax exactly; but new masonry layout uses the column syntax for rows”
  3. “Other difference is the auto-flow — grid’s indicates the primary fill direction, Chrome believes this doesn’t make sense and changed it to match the orientation of lines”
Difference in template syntax between Just use Grid vs display: masonry
  1. “Chrome argues that new display type allows better defaults — but the defaults propose aren’t good […] it doesn’t quite work as easily as claimed [see article] requires deep understanding of autosizing”
  2. “Easier to switch, e.g. at breakpoints or progressive enhancement”
  3. “Follows CSS design principles to re-use what already exists”

The TAG review

After two presentations with compelling arguments, Lea Verou (also a member of the TAG) followed with their input.

lea: We did a TAG review on this. My opinion is fully reflected there. I think the arguments WebKit team makes are compelling. We thought not only should masonry be part of grid, but should go further. A lot of arguments for integrating is that “grid is too hard”. In that case we should make grid things easier. Complex things are possible, but simple things are not so easy.

Big part of Google’s argument is defaults, but we could just have smarter defaults — there is precedent for this in CSS if we decided that would help ergonomics We agree that switching between grid vs. masonry is common. Grid might be a slightly better fallback than nothing, but minor argument because people can use @supports. Introducing all these new properties increasing the API surfaces that authors need to learn. Less they can port over. Even if we say we will be disciplined, experience shows that we won’t. Even if not intentional, accidental. DRY – don’t have multiple sources of truth

One of arguments against masonry in grid is that grids are 2D, but actually in graphic design grids were often 1D. I agree that most masonry use cases need simpler grids than general grid use cases, but that means we should make those grids easier to define for both grid and masonry. The more we looked into this, we realize there are 3 different layout modes that give you 2D arrangement of children. We recommended not just make masonry part of grid, but find ways of integrating what we already have better could we come up with a shorthand that sets grid-auto-flow and flex-direction, and promote that for layout direction in general? Then authors only need to learn one control for it.

The debate

All was laid out onto the table, it was only left what other members had to say.

oriol: Problem with Jen Simmons’s reasoning. She said the proposed masonry-direction property would be new syntax that doesn’t match grid-auto-flow property, but this property matches flex-direction property so instead of trying to be close to grid, tries to be close to flexbox. Closer to grid is a choice, could be consistent with different things.

astearns: One question I asked is, has anyone changed their mind on which proposal they support? I personally have. I thought that separate display property made a lot more sense, in terms of designing the feature and I was very daunted by the idea that we’d have to consider both grid and masonry for any new development in either seemed sticky to me but the TAG argument convinced me that we should do the work of integrating these things.

TabAtkins: Thanks for setting that up for me, because I’m going to refute the TAG argument! I think they’re wrong in this case. You can draw a lot of surface-level connections between Grid and Masonry, and Flexbox, and other hypothetical layouts but when you actually look at details of how they work, behaviors each one is capable of, they’re pretty distinct if you try to combine together, it would be an unholy mess of conflicting constraints — e.g. flexing in items of masonry or grid or you’d have a weird mish-mash of, “the 2D layout.

But if you call it a flex you get access to these properties, call it grid, access to these other properties concrete example, “pillar” example mentioned in webKit blog post, that wasn’t compatible with the base concepts in masonry and flex because it wants a shared block formatting context grid etc have different formatting contexts, can’t use floats.

lea: actually, the TAG argument was that layout seems to actually be a continuum, and syntax should accommodate that rather than forcing one of two extremes (current flex vs current grid).

The debate kept back and forth until there was an attempt to set a general north star to follow.

jyasskin: Wanted to emphasize a couple aspects of TAG review. It seems really nice to keep the property from Chrome proposal that you don’t have to learn both, can just learn to do masonry without learning all of Grid even if that’s in a unified system perhaps still define masonry shorthand, and have it set grid properties

jensimmons: To create a simple masonry-style layout in Grid, you just need 3 lines of code (4 with a gap). It’s quite simple.

jyasskin: Most consensus part of TAG feedback was to share properties whenever possible. Not necessary to share the same ‘display’ values; could define different ‘display’ values but share the properties. One thing we didn’t like about unified proposal was grid-auto-flow in the unified proposal, where some values were ignored. Yeah, this is the usability point I’m pounding on

Another Split Decision

Despite all, it looked like nobody was giving away, and the debate seemed stuck once again:

astearns: I’m not hearing a way forward yet. At some point, one of the camps is going to have to concede in order to move this forward.

lea: What if we do a straw poll. Not to decide, but to figure out how far are we from consensus?

The votes were cast and the results were… split.

Straw Poll for Just Use Grid, showing a split result. 12 NOs, 13 YES and 5 ABSTAIN

florian: though we could still not reach consensus, I want to thank both sides for presenting clear arguments, densely packed, well delivered. I will go back to the presentations, and revisit some points, it really was informative to present the way it was.

That’s all folks, a split decision! There isn’t a preference for either of the two proposals and implementing something with such mixed opinions is something nobody would approve. After a little over five years of debate, I think this meeting is yet another good sign that a new proposal addressing the concerns of both sides should be considered, but that’s just a personal opinion. To me, masonry (or whatever name it may be) is an important step in CSS layout that may shape future layouts, it shouldn’t be rushed so until then, I am more than happy to wait for a proposal that satisfies both sides.

Further Reading

Relevant Issues


CSSWG Minutes Telecon (2024-12-04): Just Use Grid vs. Display: Masonry originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.