4 ways CSS :has() can make your HTML forms even better

Austin Gil - Dec 22 '22 - - Dev Community

There’s been a lot of hype lately around the CSS :has() pseudo-class. And rightly so! It’s basically the “parent selector” we’ve been asking for for years. Today I want to focus on ways we can use :has() to make HTML forms even better.

(There’s a Codepen demo at the end)

Preface

In this article I’ll be working with custom form controls that look like this:

<div class="control">
    <label for="name">Name</label>
    <div class="control__input">
        <input type="text" id="name" name="name" required>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

<div> wrapping the whole control, a <label> to keep the input accessible, a wrapper <div> around the input element, and the <input> element itself. The input element will have no border or background. The “control__input” <div> will have the styling to serve as the visual representation of the form control.

.control :where(input, select, textarea) {
  border: 0;
  background-color: transparent;
}
.control__input {
  display: flex;
  align-items: center;
  gap: .125rem;
  border: .125rem solid;
  border-radius: .25rem;
  padding: .25rem;
}
Enter fullscreen mode Exit fullscreen mode

An input component with a black border and the label "Name" above it.

The border is around the div, and inside there somewhere is a little input. Trust me.

This approach allows us to do add small design embellishments like an “at” symbol when the input type is “email”.

An input component with a black border and the label "Email" above it. The input has an @ symbol decorating the inside of it.

Having elements in the DOM makes it easier to apply styling to them or animations. It’s also going to be useful for the examples in this post.

Custom Focus Styles

CSS provides a lot of nice pseudo-classes for styling elements. One that is particularly helpful for form inputs is :focus-visible which lets us apply styles to elements when they are focused via keyboard navigation.

When our input receives focus, it would be nicer to apply focus styles on the wrapper. We can do that with :has():

.control__input:has(:focus-visible) {
  outline: 3px solid plum;
}
Enter fullscreen mode Exit fullscreen mode

An input component with a black border and the label "Name" above it. The input has a purple outline.

Inline Validation Feedback

What if we wanted to give the user feedback if the input is valid or invalid? We can do that with the :valid and :invalid pseudo-classes.

Let’s add a couple of SVGs inside our input wrapper to provide some visual feedback. We’ll set them to display:none by default so you don’t see them.

<div class="control">
    <label for="name">Name</label>
    <div class="control__input">
        <input type="text" id="name" name="name" required>
        <svg class="icon icon-check" role="presentation">
            <use href="#icon-check"></use>
        </svg>
        <svg class="icon icon-cancel" role="presentation">
            <use href="#icon-cancel"></use>
        </svg>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Using the :has() pseudo-class, we can display the appropriate SVG as well as adding a color the entire form control; green for valid feedback and red for invalid feedback.

To avoid providing too much feedback at once, we’ll also use the :focus pseudo-classes to only apply styles to the input that is currently focused.

.control__input is(.icon-cancel, .icon-check) {
  display: none;
}
.control:has(:focus:invalid) .icon-cancel,
.control:has(:focus:valid) .icon-check {
  display: unset
}
.control:has(:focus:invalid) {
  color: tomato;
}
.control:has(:focus:valid) {
  color: limegreen;
}
.control:has(:focus-visible:invalid) .control__input {
  outline-color: pink;
}
.control:has(:focus-visible:valid) .control__input {
  outline-color: palegreen;
}
Enter fullscreen mode Exit fullscreen mode

We can see that when the control’s input receives focus and the input has an invalid state, the entire form control turns red and you can see the “cancel” icon. As the user types, when the input becomes valid, the entire form control turns green and you can see the “check” icon.

An input component with a red border and the label "Name" above it. The input has a "cancel" icon inside it. The text, icon, and outline is red.

An input component with a green border and the label "Name" above it. The input has a "check" icon inside it. The text, icon, and outline is green.

It’s convenient to color things at the top level because those styles can propagate down to the label, the input wrapper’s border, the input text, and the icon SVG’s fill.

This effect also works with select elements, but I recommend resetting the option elements to their default color. Otherwise, they inherit the green and red colors.

.control option {
  color: initial;
}
Enter fullscreen mode Exit fullscreen mode

Note that adding icons and changing colors is not a complete validation strategy as it doesn’t work for visually impaired users and does not convey what the problems are with the input. This addition should accompany clear descriptions of input constraints and validation error messages.

Jhey Tompkins takes this concept even further using hidden placeholders and the :placeholder-shown pseudo-class to only add feedback after the user has interacted with the input. Check out his amazing demo here: https://codepen.io/jh3y/pen/yLKMOBm

I love the concept, but the :placeholder-shown trick has always felt a bit hacky for me because it removes the functionality of placeholders. In the future we should have :user-invalid which is intended for this exact use-case. Currently it’s only supported in Firefox.

Card-like Input Options

Consider a form that asks you what your favorite frontend language is along with its name, logo, and description. It might look something like this:

A form control with the label, "What's your fave frontend language". There are three options in the shape of card elements with an icon, a title, and a description. The first option is, "HTML; The bones of any good website". The second is, "CSS; Styles to make your mamma proud". The third is, "JavaScript; Fancy-pantsy movements and stuff"

Unfortunately, the web doesn’t have a <input type="card"> option for us. But we’re developers, so we’ll build one using what’s available.

Since the UI presents a question that can have only one answer, the best tool for the job is three <input type="radio"> elements. We’ll use a <fieldset> to semantically group the inputs together. The inputs need a <label>. It might be tempting to wrap the entire card with the label element so users can click the card and select the input, but then the entire contents of the card would be read out to screen readers and I’d like to avoid that. Instead, it makes sense to use the language name as the label. We’ll sort out making the whole card clickable later. Lastly, since there is already a small description in the UI, we might as well associate that to the input using the aria-describedby attribute.

<fieldset>
  <legend>What's your fave frontend language</legend>
  <div class="cards">
    <div class="card card--html">
      <img src="/img/logo-html.svg" alt="HTML logo" width="48" height="48">
      <label for="html">HTML</label>
      <input id="html" type="radio" name="fe-fave" aria-describedby="html-description" class="visually-hidden">
      <p id="html-description">The bones of any good website</p>
    </div>

    <! – CSS card markup – >

    <! – JavaScript card markup – >
  </div>
</fieldset>
Enter fullscreen mode Exit fullscreen mode

Based on the design, you may notice an obvious lack of radio inputs. The first thing we want to do with styles is make sure that our radio inputs are not visible, but still accessible. We can’t use display:none because that would remove it from the document. Then it wouldn’t be clickable or keyboard accessible. Instead, we’ll use a common pattern with a class called “visually-hidden”. It looks like this:

.visually-hidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: auto;
  margin: 0;
  overflow: hidden;
  padding: 0;
  position: absolute !important;
  width: 1px;
  white-space: nowrap;
}
Enter fullscreen mode Exit fullscreen mode

Next, I want users to be able to click anywhere in the card to select it, but I don’t want to ruin the accessibility. There’s another handy pattern for cards like this in Inclusive Components.

We can add a the :after pseudo-element to the label and position it to cover the entire card. This has the affect of making the <label> behave as if it covers the entire card. User’s can click anywhere on the card, which hits the label element, which activates the associated input.

.card {
  position: relative;
}
.card label:after {
  content: '';
  position: absolute;
  inset: 0;
}
.card a {
  position: relative;
}
Enter fullscreen mode Exit fullscreen mode

Other interactive elements within the card should be position:relative so that they can still be clicked.

Ok, mouse users are taken care of, let’s also account for keyboard users.

We did a good job making the inputs accessible for keyboards, but users will not see their focus outlines because they are visually hidden.

Here’s where :has() can help us out.

We can target any card whose input has the :focus-visible pseudo-class, and apply an outline to the card.

.card:has(input:focus-visible) {
  outline: 3px solid plum;
}
Enter fullscreen mode Exit fullscreen mode

The input remains visually hidden, but the card receives an outline.

A form control with the label, "What's your fave frontend language". There are three options in the shape of card elements with an icon, a title, and a description. The first option is, "HTML; The bones of any good website". The second is, "CSS; Styles to make your mamma proud". The third is, "JavaScript; Fancy-pantsy movements and stuff". There is a purple outline around the HTML card.

The last part of this trick is to provide some visual feedback for which input is currently active. For that, we can do a similar trick to the last one, but using the :checked pseudo-class.

When a card contains a checked input, apply styles; in this case, a box-shadow.

.card:has(:checked) {
  box-shadow: inset 0 0 0 .25em mediumpurple;
}
Enter fullscreen mode Exit fullscreen mode

A form control with the label, "What's your fave frontend language". There are three options in the shape of card elements with an icon, a title, and a description. The first option is, "HTML; The bones of any good website". The second is, "CSS; Styles to make your mamma proud". The third is, "JavaScript; Fancy-pantsy movements and stuff". There is a purple, inset box-shadow on the HTML card.

(I should have used checkboxes so I could select all three)

This example is probably the least dependent on :has() because it’s not so hard to accomplish the same without it. You would have to move the input before the card element, then use a CSS sibling combinator to target the card (input:checked + .card { /* styles */ }).

So while this example is probably the easiest to live without :has(), having it around makes our lives easier by co-locating the input and label in the DOM .

Conditional Content Rendering

The next cool thing I want to showcase is showing and hiding different parts of the DOM using :has().

Consider this UI:

A fieldset form control with the label, "Favorite Starter Pokemon" and the radio options, "Bulbasaur", "Charmander", and "Squirtle"

Once again, we have a question with a few options, but only one can be selected. So once again, we’ll use a <fieldset> with some radio inputs.

<fieldset>
  <legend>Favorite Starter Pokemon</legend>
  <div>
    <input id="bulbasaur" type="radio" name="poke" value="bulbasaur" />
    <label for="bulbasaur">Bulbasaur</label>
  </div>
  <! – charmander form control – >
  <! – bulbasaur form control – >
</fieldset>
Enter fullscreen mode Exit fullscreen mode

So far, nothing special. But things get interesting when we select one of the options. We can reveal content based on which selection was made.

A fieldset form control with the label, "Favorite Starter Pokemon" and the radio options, "Bulbasaur", "Charmander", and "Squirtle". The "Bulbasaur" input is selected. Below is a picture of Bulbasaur along with the text, "Bulbasaur can be seen napping in bright sunlight. There is a seed on its back. By soaking up the sun's rays, the seed grows progressively larger. Height: 2ft 04in Weight: 15.2 lbs Type: Grass/Poison Weaknesses: Fire, Psychic, Flying, Ice"

So let’s say that somewhere else within the form we have the items we want to reveal.

<form>
  <div class="pokemon pokemon--bulbasaur">
    <img src="img/bulbasaur.png" width="300" height="300" alt="Bulbasaur" />
    <p>Bulbasaur can be seen napping in bright sunlight. There is a seed on its back. By soaking up the sun's rays, the seed grows progressively larger.</p>
    <ul>
      <li>Height: 2' 04"</li>
      <li>Weight: 15.2 lbs</li>
      <li>Type: Grass/Poison</li>
      <li>Weaknesses: Fire, Psychic, Flying, Ice</li>
    </ul>
  </div>
  <! – charmander details – >
  <! – bulbasaur details – >
</form>
Enter fullscreen mode Exit fullscreen mode

We can hide those elements by default, then use :has() to find the specific input that was checked, and reveal it’s corresponding element somewhere else within the form.

.pokemon {
  display: none;
}
form:has(#bulbasaur:checked) .pokemon--bulbasaur,
form:has(#charmander:checked) .pokemon--charmander,
form:has(#squirtle:checked) .pokemon--squirtle {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

When I choose Bulbasaur, I see Bulbasaur’s details. When I choose Charmander, I see Charmander’s details. And when I choose Squirtle, I see Squirtle’s details.

(By the way, Bulbasaur is objectively the best starter: Only one with multiple types. Best type against early gyms leaders. And there are stronger fire and water types in the game, but Venosaur is the strongest plant type so you can build a better team later on)

This pattern can also work with <select> or checkboxes. For example, if you wanted to make a pizza ordering form, you might offer toppings as checkbox inputs. Then in the order review area, you can list the selected toppings. Pretty cool!

I haven’t found a way to make this work across many different implementations without explicitly adding add the different ID’s and classes. So the downside is that your CSS will grow linearly with each implementation.

With that in mind, if you only need to do this a few times, CSS is a great choice. If you want to show/hide elements throughout several places in your application, JavaScript may be a better choice.

It’s also worth mentioning that whenever you show and hide content, you’ll want to think of the accessibility concerns. In this scenario, the content comes immediately after the interactive element (form control). That makes it easy for assistive technology users to discover what has changed. But if the content we before the control, or far away from it, it may require JavaScript to improve the experience using tools like aria-expanded or aria-controls.

Anyway, my goal here was to show you what’s possible. Which is awesome!

Form-level Validation Hints

The last thing I wanna show off today is a form’s submit button. Let’s set up a form with a required checkbox before the form can be submitted. Wouldn’t it be cool if we gave some sort of visual feedback when the form is invalid vs. valid?

An unchecked checkbox and a button with the text "Submit". The checkbox is labeled, "I learned something cool!" The button is grey and slightly opaque.

A checked checkbox and a button with the text "Submit". The checkbox is labeled, "I learned something cool!" The button is purple with white text.

We can do that with :has()

<form>
  <div class="control">
    <input id="checkme" type="checkbox" name="learned" required />
    <label for="checkme">I learned something cool!</label>
  </div>

  <button type="submit">Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

In the markup above, we have a required checkbox. If it’s unchecked, that it will satisfy the :invalid pseudo-class.

We can check if the form has any invalid input and apply styles to the submit button accordingly.

form:has(:invalid) :where(button:not([type]), button[type="submit"]) {
  opacity: 0.7;
  color: black;
  background: whitesmoke;
  cursor: not-allowed;
}
Enter fullscreen mode Exit fullscreen mode

We only target buttons that are missing the type attribute, or whose type attribute is set to “submit”. That way we don’t accidentally apply styles to <button type="button">.

This won’t actually prevent the form from being submitted and that’s good for validation and accessibility reasons. Instead, we add a few additional cues to tell visual users, “hey, you may want to look over the form one more time”. CSS is great for a lot of things. Actual validation is not one of them (yet).

Anyway, when I have a valid form, I can show the button is ready to go. When I have an invalid form, I can show the button is not ready.

I think that’s pretty cool.

Closing Thoughts

In addition to all the other cool places to use :has(), forms offer some of my favorite use cases. Many things that used to require JavaScript can now be done using only CSS.

Here’s a Codepen with all the demos above:

Unfortunately, :has() doesn’t quite have the browser support we need today for it to be production ready.

Fortunately, many of the examples above do not strictly require :has(). Using different markup and sibling combinators you could accomplish the same or something sort of close. Those methods are a bit harder to maintain, but we’re not too far off from doing things the easier way with :has().

I’m pretty excited for it.

If you have any other ideas or interesting ways to use :has(), especially if it’s in forms but not exclusively, please let me know I would love to see what sort of cool stuff you are building with it.

Thank you so much for reading. If you liked this article, please share it. It's one of the best ways to support me. You can also sign up for my newsletter or follow me on Twitter if you want to know when new articles are published.


Originally published on austingil.com.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .