visit
There’s been a lot of hype lately around the :has()
:has()
to make
In this article I’ll be working with custom
<div class="control">
<label for="name">Name</label>
<div class="control__input">
<input type="text" id="name" name="name" required>
</div>
</div>
A <div>
wrapping the whole control, a <label>
to keep the input <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;
}
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”.
Having elements in the
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;
}
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>
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;
}
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.
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;
}
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.
:placeholder-shown
pseudo-class to only add feedback after the user has interacted with the input. Check out his amazing demo here:
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.
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>
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
.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;
}
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
We can add a the :after
<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;
}
Other interactive elements within the card should be position:relative
so that they can still be clicked.
Here’s where :has()
can help us out.
.card:has(input:focus-visible) {
outline: 3px solid plum;
}
The input remains visually hidden, but the card receives an outline.
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
.card:has(:checked) {
box-shadow: inset 0 0 0 .25em mediumpurple;
}
(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 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 .
The next cool thing I want to showcase is showing and hiding different parts of the DOM using :has()
.
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>
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.
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>
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;
}
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,
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!
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
.
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>
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;
}
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 (
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.
Unfortunately, :has()
doesn’t quite have
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()
.
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
Originally published .