This article is not a definitive guide to :has(). It’s also not here to regurgitate what’s already been said. It’s just me (hi 👋) jumping on the bandwagon for a moment to share some of the ways I’m most likely to use :has() in my day-to-day work… that is, once it is officially supported by Firefox which is imminent.
When that does happen, you can bet I’ll start using :has() all over the place. Here are some real-world examples of things I’ve built recently and thought to myself, “Gee, this’ll be so much nicer once :has() is fully supported.”
Avoid having to reach outside your JavaScript component
Have you ever built an interactive component that sometimes needs to affect styles somewhere else on the page? Take the following example, where <nav> is a mega menu, and opening it changes the colors of the <header> content above it.
I feel like I need to do this kind of thing all the time.
This particular example is a React component I made for a site. I had to “reach outside” the React part of the page with document.querySelector(...) and toggle a class on the <body>, <header>, or another component. That’s not the end of the world, but it sure feels a bit yuck. Even in a fully React site (a Next.js site, say), I’d have to choose between managing a menuIsOpen state way higher up the component tree, or do the same DOM element selection — which isn’t very React-y.
With :has(), the problem goes away:
header:has(.megamenu--open) { /* style the header differently if it contains an element with the class ".megamenu--open" */ }
No more fiddling with other parts of the DOM in my JavaScript components!
Better table striping UX
Adding alternate row “stripes” to your tables can be a nice UX improvement. They help your eyes keep track of which row you’re on as you scan the table.
But in my experience, this doesn’t work great on tables with just two or three rows. If you have, for example, a table with three rows in the <tbody> and you’re “striping” every “even” row, you could end up with just one stripe. That’s not really worth a pattern and might have users wondering what’s so special about that one highlighted row.
What to get fancier? You could also decide to only do this if the table has at least a certain number of columns, too:
table:has(:is(td, th):nth-child(3)) { /* only do stuff if there are three or more columns */ }
Remove conditional class logic from templates
I often need to change a page layout depending on what’s on the page. Take the following Grid layout, where the placement of the main content changes grid areas depending on whether there’s a sidebar present.
That’s something that might depend on whether there are sibling pages set in the CMS. I’d normally do this with template logic to conditionally add BEM modifier classes to the layout wrapper to account for both layouts. That CSS might look something like this (responsive rules and other stuff omitted for brevity):
/* m = main content */ /* s = sidebar */ .standard-page--with-sidebar { grid-template-areas: 's s s m m m m m m m m m'; } .standard-page--without-sidebar { grid-template-areas: '. m m m m m m m m m . .'; }
CSS-wise, this is totally fine, of course. But it does make the template code a little messy. Depending on your templating language it can get pretty ugly to conditionally add a bunch of classes, especially if you have to do this with lots of child elements too.
Contrast that with a :has()-based approach:
/* m = main content */ /* s = sidebar */ .standard-page:has(.sidebar) { grid-template-areas: 's s s m m m m m m m m m'; } .standard-page:not(:has(.sidebar)) { grid-template-areas: '. m m m m m m m m m . .'; }
Honestly, that’s not a whole lot better CSS-wise. But removing the conditional modifier classes from the HTML template is a nice win if you ask me.
It’s easy to think of micro design decisions for :has() — like a card when it has an image in it — but I think it’ll be really useful for these macro layout changes too.
Better specificity management
If you read my last article, you’ll know I’m a stickler for specificity. If, like me, you don’t want your specificity scores blowing out when adding :has() and :not() throughout your styles, be sure to use :where().
That’s because the specificity of :has() is based on the most specific element in its argument list. So, if you have something like an ID in there, your selector is going to be tough to override in the cascade.
/* specificity score: 0,1,0. Same as a .standard-page--with-sidebar modifier class */ .standard-page:where(:has(.sidebar)) { /* etc */ }
The future’s bright
These are just a few things I can’t wait to be able to use in production. The CSS-Tricks Almanac has a bunch of examples, too. What are you looking forward to doing with :has()? What sort of some real-world examples have you run into where :has() would have been the perfect solution?
CSS ::before and ::after pseudo-elements allow you to insert “content” before and after any non-replaced element (e.g. they work on a <div> but not an <input>). This effectively allows you to show something on a web page that might not be present in the HTML content. You shouldn’t use it for actual content because it’s not very accessible in that you can’t even select and copy text inserted on the page this way — it’s just decorative content.
In this article, I’ll walk you through seven different examples that showcase how ::before and ::after can be used to create interesting effects.
Note that for most examples, I am only explaining the parts of the code that deal specifically with CSS pseudo-elements. That said, all of the CSS is available in the embedded demos if you want to see the code for additional styling.
Styling Broken images
When a user visits your website, their internet connection (or a factor beyond your control) might prevent your images from downloading and, as a result, the browser shows a broken image icon and and the image’s alt text (if it’s actually there).
How about showing a custom placeholder instead? You can pull this off using ::before and ::after with a bit of CSS positioning.
First, we need to use relative positioning on the image element. We are going to use absolute positioning on one of the pseudo-elements in a bit, so this relative position makes sure make sure the pseudo-element is positioned within the content of the image element, rather than falling completely out of the document flow.
img { display: block; /* Avoid the space under the image caused by line height */ position: relative; width: 100% }
Next, let’s create the region of the broken image effect using the image’s ::before pseudo-element. We’re going to style this with a light gray background and slightly darker border to start.
<img> is a replaced element. Why are you using ::before pseudo-element on it? It wont work!. Correct. In this scenario the pseudo-element will show in Chrome and Firefox when the image fails to load, which is exactly what you want. Meanwhile, Safari only shows the styling applied to the alt text.
The styling is applied to the top-left corner of the broken image.
So far, so good. Now we can make it a block-level element (display: block) and give it a height that fills the entire available space.
We can refine the style a little more. For example, let’s round the corners. We should also give the alt text a little breathing room by giving the pseudo-element full width and absolute positioning for better control placing things where we want.
If you stopped here and checked your work, you might be scratching your head because the alt text is suddenly gone.
That’s because we set content to an empty string (which we need to display our generated content and styles) and cover the entire space, including the actual alt text. It’s there, we just can’t see it.
We can see it if we display the alt text in an alternate (get it?) way, this time with help form the ::after pseudo-element. The content property is actually capable of displaying the image’s alt attribute text using the attr() function:
The generated content is colliding with the actual alt text in Firefox.
A quick fix is to target the alt attribute directly using an attribute selector (in this case, img[alt]), and target similar styles there so things match up with Chrome.
Now we have a great placeholder that’s consistent in Chrome and Firefox.
Custom blockquote
Blockquotes are quotes or an excerpts from a cited work. They’re also provide a really great opportunity to break up a wall of text with something that’s visually interesting.
I want to look at another technique, one that incorporates ::before and ::after. Like we saw with the last example, we can use the content property to display generated content, and apply other properties to dress it up. Let’s put large quotation marks at the start and end of a blockquote.
Firefox 91
The HTML is straightforward:
<blockquote> <!-- Your text here --> </blockquote>
Note the position: relative in there because, as you’ll learn, it’s essential for positioning the blockquotes.
As you’ve probably guessed, we’re going to use ::before for the first quotation mark, and ::after for the closing one. Now, we could simply call the content property on both and generate the marks in there. But, CSS has us covered with open-quote and close-quote values.
blockquote::before { content: open-quote; /* Place it at the top-left */ top: 0; left: 0; } blockquote::after { content: close-quote; /* Place it at thee bottom-right */ bottom: 0; right: 0; }
This gets us the quotation marks we want, but allow me to button up the styles a bit:
We have ordered (<ol>) and unordered (<ul>) lists in HTML. Both have default styling dictated by the browser’s User Agent stylesheet. But with ::before pseudo-element, we can override those “default” styles with something of our own. And guess what? We can use emojis (😊) on the content property!
While this is great and all, it’s worth noting that we could actually reach for the ::marker pseudo-element, which is designed specifically for styling list markers. Eric Meyer shows us how that works and it’s probably a better way to go in the long run.
The customization is all thanks to modifications added to the <input> element via the ::before and ::after pseudo-elements. But first, here is some baseline CSS for the <form> element:
We’re not quite there, but see how the checkbox element is displayed.
We’re going to “hide” the checkbox’s default appearance while making it take up the full amount of space. Weird, right? It’s invisible but still technically there. We do that by:
changing its position to absolute,
setting the appearance to none, and
setting its width and height to 100%.
input { -webkit-appearance: none; /* Safari */ cursor: pointer; /* Show it's an interactive element */ height: 100%; position: absolute; width: 100%; }
Now, let’s style the <input> element with its ::before pseudo-element. This styling will change the appearance of the input, bringing us closer to the final result.
input::before { background: #fff; border-radius: 50px; content: ""; height: 70%; position: absolute; top: 50%; transform: translate(7px, -50%); /* Move styling to the center of the element */ width: 85%; }
Next, we need to create the “toggle” button and it just so happens we still have the ::after pseudo-element available to make it. But, there are two things worth mentioning:
The background is a linear-gradient.
The “toggle” button is moved to the center of the <input> with the transform property.
Try clicking on the toggle button. Nothing happens. That’s because we’re not actually changing the checked state of the <input>. And even if we were, the result is… unpleasant.
The fix is to apply the :checked attribute to the ::after pseudo-element of the <input>. By specifically targeting the checked state of the checkbox and chaining it to the ::after pseudo-element, we can move the toggle back into place.
We can decorate images with borders to make them stand out or fit more seamlessly within a design. Did you know we can use a gradient on a border? Well, we can with ::before (there are other ways, too, of course).
The core idea is to create a gradient over the image and use the CSS z-index property with a negative value. The negative value pulls the gradient below the image in the stacking order. This means the image always appears on top as long as the gradient has a negative z-index.
.gradient-border::before { /* Renders the styles */ content: ""; /* Fills the entire space */ position: absolute; top: 0; left: 0; bottom: 0; right: 0; /* Creates the gradient */ background-image: linear-gradient(#1a1a1a, #1560bd); /* Stacks the gradient behind the image */ z-index: -1; } figure { /* Removes the default margin */ margin: 0; /* Squeezes the image, revealing the gradient behind it */ padding: 10px; }
Gradient overlays
This is similar to what we did in the previous example, but here, we’re applying the gradient on top of the image. Why would we do that? It can be a nice way to add a little texture and depth to the image. Or perhaps it can be used to either lighten or darken an image if there’s text on top it that needs extra contrast for legibility.
While this is similar to what we just did, you’ll notice a few glaring differences:
See that? There’s no z-index because it’s OK for the gradient to stack on top of the image. We’re also introducing transparency in the background gradient, which lets the image bleed through the gradient. You know, like an overlay.
Custom radio buttons
Most, if not all, of us try to customize the default styles of HTML radio buttons, and that’s usually accomplished with ::before and ::after, like we did with the checkbox earlier.
Firefox 91
We’re going to set a few base styles first, just to set the stage:
::before should be positioned at the top-left corner of the radio button, and when it’s checked, we change its background color.
.form-input::before { /* Renders the styles */ content: ''; /* Shows that it's interactive */ cursor: pointer; /* Positions it to the top-left corner of the input */ position: absolute; top: 0; left: 0; /* Takes up the entire space */ height: 100%; width: 100%; } /* When the input is in a checked state... */ .form-input:checked::before { /* Change the background color */ background: #21209c; }
We still need to iron a few things out using ::after. Specifically, when the radio button is checked, we want to change the color of the circular ring to white because, in its current state, the rings are blue.
.form-input::after { /* Renders the styles */ content: ''; /* Shows that it's interactive */ cursor: pointer; /* A little border styling */ border-radius: 50px; border: 4px solid #21209c; /* Positions the ring */ position: absolute; left: 10%; top: 50%; transform: translate(0, -50%); /* Sets the dimensions */ height: 15px; width: 15px; } /* When the input is in a checked state... */ .form-input:checked::after { /* Change ::after's border to white */ border: 4px solid #ffffff; }
The form label is still unusable here. We need to target the form label directly to add color, and when the form input is checked, we change that color to something that’s visible.
Click the buttons, and still nothing happens. Here what’s going on. The position: absolute on ::before and ::after is covering things up when the radio buttons are checked, as anything that occurs in the HTML document hierarchy is covered up unless they are moved to a new location in the HTML document, or their position is altered with CSS. So, every time the radio button is checked, its label gets covered.
You probably already know how to fix this since we solved the same sort of thing earlier in another example? We apply z-index: 1 (or position: absolute) to the form label.
.form-label { color: #21209c; font-size: 1.1rem; margin-left: 10px; z-index: 1; /* Makes sure the label is stacked on top */ /* position: absolute; This is an alternative option */ }
Wrapping up
We covered seven different ways we can use the ::before and ::after pseudo-elements to create interesting effects, customize default styles, make useful placeholders, and add borders to images.
By no means did we cover all of the possibilities that we can unlock when we take advantage of these additional elements that can be selected with CSS. Lynn Fisher, however, has made a hobby out of it, making amazing designs with a single element. And let’s not forget Diana Smith’s CSS art that uses pseudo-elements in several places to get realistic, painting-like effects.
A little interview with me over on Uses This. I’ll skip the intro since you know who I am, but I’ll republish the rest here.
What hardware do you use?
I’m a fairly cliché Mac guy. After my first Commodore 64 (and then 128), the only computers I’ve ever had have been from Apple. I’m a longtime loyalist in that way and I don’t regret a second of it. I use the 2018 MacBook Pro tricked out as much as they would sell it to me. It’s the main tool for my job, so I’ve always made sure I had the best equipment I can. A heaping helping of luck and privilege have baked themselves into moderate success for me such that I can afford that.
At the office, I plug it into two of those LG UltraFine 4k monitors, a Microsoft Ergonomic Keyboard, and a Logitech MX Master mouse. I plug in some Audioengine A2s for speakers. Between all those extras, the desk is more cluttered in wires than I would like and I look forward to an actually wireless future.
I’m only at the office say 60% of the time and aside from that just use the MacBook Pro as it is. I’m probably a more efficient coder at the office, but my work is a lot of email and editing and social media and planning and such that is equally efficient away from the fancy office setup.
And what software?
Notion for tons of stuff. Project planning. Meeting notes. Documentation. Public documents.
I’d happily upgrade to a tricked out 16″ MacBook Pro. If I’m just throwing money at things I’d also happily take Apple’s Pro Display XDR, but the price on those is a little frightening. I already have it pretty good, so I don’t do a ton of dreaming about what could be better.
You might not know this, but JavaScript has stealthily accumulated quite a number of observers in recent times, and Intersection Observer is a part of that arsenal. Observers are objects that spot something in real-time — like birdwatchers going to their favorite place to sit and wait for the birds to come.
Different observers observe different things (not everyone watches hawks).
The very first observer I came to know was the Mutation Observer that looks for changes to the DOM tree. It was a one-of-a-kind at the time, but now we have many more observers.
Intersection Observer observes the “intersection” (i.e. the passing across) of an element through one of its ancestor elements or the area on screen where the page is visible (aka the viewport).
It’s sort of like watching a train pass through a station. You can see when the train comes in, when it leaves, and how long it was stationary.
Knowing when an element is about to come into view, if it has gone out of view, or how long it’s been since it came into view all have useful applications. So, we’ll see some of those use cases now — right after seeing the code for creating an IntersectionObserver object by way of the Intersection Observer API.
A quick overview of IntersectionObserver
The Intersection Observer API has already gained wide support at the time of this writing.
This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
Desktop
Chrome
Opera
Firefox
IE
Edge
Safari
58
45
55
No
16
12.1
Mobile / Tablet
iOS Safari
Opera Mobile
Opera Mini
Android
Android Chrome
Android Firefox
12.2
46
No
67
74
66
But if you want to check whether Intersection Observer is supported while you’re working with it, you could see if the property IntersectionObserver exists in the window object:
if(!!window.IntersectionObserver){} /* or */ if('IntersectionObserver' in window){}
OK, now for a look at the object creation:
var observer = new IntersectionObserver(callback, options);
The IntersectionObserver object’s constructor takes two parameters. The first one is a callback function that’s executed once the observer notices an intersection and has asynchronously delivered some data about that intersection.
The second (optional) parameter is options, an object with information to define what’s going to be the “intersection.” We may not want to know when an element is about to come into view, but only when it’s fully visible. Something like that is defined through the options parameter.
Options has three properties:
root – The ancestor element/viewport that the observed element will intersect. Think of it as the train station that the train will intersect.
rootMargin – A perimeter of the root element, shrinking or growing the root element’s area to watch out for intersection. It’s similar to the CSS margin property.
threshold – An array of values (between 0 and 1.0), each representing the distance an element has intersected into or crossed over in the root at which the callback is to be triggered.
Let’s say our threshold is 0.5. The callback is triggered when the element is in or passes its half-visible threshold. If the value is [0.3, 0.6], then the callback is triggered when the element is in or passes its 30% visible threshold and also, its 60% visible threshold.
That’s enough of the theory now. Let’s see some demos. First up, lazy loading.
To see the loading at the mark, view this webpage since the embedded demo doesn’t show that.
CSS-Tricks has thoroughly covered lazy loading in before and it’s typically done like this: display a lightweight placeholder where images are intended, then swap them out for the intended images as they come (or are about to come) into view. Believe me, there’s nothing lazy about implementing this — that is, until we get something native to work with.
We’ll apply the same mechanics. First, we’ve a bunch of images and have defined a placeholder image to display initially. We’re using a data attribute carrying the URL of the original image to be shown that defines the actual image to load when it comes into view.
let observer = new IntersectionObserver( (entries, observer) => { entries.forEach(entry => { /* Here's where we deal with every intersection */ }); }, {rootMargin: "0px 0px -200px 0px"});
The callback function above is an arrow function (though you can use a normal function instead).
The callback function receives two parameters: a set of entries carrying the information about each intersection and the observer itself. Those entries can be filtered or looped through so we can then deal with the intersection entries that we want. As for the options, I’ve only provided the rootMargin value, letting the root and threshold properties fall into their default values.
The root default is the viewport and threshold default is 0 — which can be roughly translated to “ping me the very moment that the element appears in the viewport!”
The oddity, though, is that I reduced the viewport’s observation area by two hundred pixels at the bottom using rootMargin. We wouldn’t typically do this for lazy loading. Instead, we might increase the margin or let it default. But reducing isn’t something we would usually do in this situation. I did it only because I want to demonstrate the original images loading at the threshold in the observed area. Otherwise, all the action would happen out of view.
When the image intersects the viewport’s observer area (which is 200px above the bottom in the demo), we replace the placeholder image with the actual image.
entry.target is the element observed by the observer. In our case, those are the image elements. Once the placeholder is replaced in an image element, we don’t have to observe it anymore, so we call the observer’s unobserve method for it.
Now that the observer is ready, it’s time to start observing all the images by using its observer method:
That’s it! we’ve lazy loaded the images. Onto the next demo.
Use Case 2: Auto-pause video when it’s out of view
Let’s say we’re watching a video on YouTube and (for whatever reason) we want to scroll down to read the comments. I don’t know about you, but I don’t usually pause the video first before doing that, which means I miss some of the video while I’m browsing.
Wouldn’t it be nice if the video paused for us when we scroll away from it? It would be even nicer if the video resumed when it’s back in view so there’s no need to hit Play or Pause at all.
let video = document.querySelector('video'); let isPaused = false; /* Flag for auto-paused video */ let observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if(entry.intersectionRatio!=1 && !video.paused){ video.pause(); isPaused = true; } else if(isPaused) {video.play(); isPaused=false} }); }, {threshold: 1}); observer.observe(video);
Before I show you how we’re pausing and playing the video during each intersection (i.e. entry), I want to bring your attention to the threshold property of the options.
Th threshold has a value of 1. Both root and rootMargin will default. This is the same as saying, “hey, let me know as soon the element is fully visible on the viewport.”
Once the intersection happens and the callback is triggered, we pause or play the video based on this logic:
I have not called unobserve for the video, so the observer keeps observing the video and pauses every time it goes out of view.
Use Case 3: See how much content is viewed
This can be interpreted and implemented in many ways depending on what your content is and the way you prefer to measure how much of it has been viewed.
For a simple example, we’ll observe the last paragraph of every article in a list of articles on a page. Once an article’s last paragraph becomes fully visible, we will consider that article read — like how we might say that seeing the last coach of a train counts as having seen the whole train.
Here’s a demo that shows two articles on a page, each containing a number of paragraphs.
<div id="count"><!-- The place where "number of articles viewed" is displayed --></div> <h2>Article 1</h2> <article> <p><!-- Content --></p> <!-- More paragraphs --> </article> <h2>Article 2</h2> <article> <p><!-- Content --></p> <!-- More paragraphs --> </article> <!-- And so on... -->
let n=0; /* Total number of articles viewed */ let count = document.querySelector('#count'); let observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if(entry.isIntersecting){ count.textContent= `articles fully viewed - $ {++n}`; observer.unobserve(entry.target); } }); }, {threshold: 1}); document.querySelectorAll('article > p:last-child').forEach(p => { observer.observe(p) });
During each intersection — the full view of the last paragraph of an article — we’re incrementing a count: n, that represents the total number of articles read. Then we display that number above the list of articles.
Once we’ve counted in an intersection of the last paragraph, it doesn’t need to be observed anymore, so we call unobserve for it.
Thanks for observing along!
That’s it for the examples we’re going to look at together for this post. You probably get the idea of how using it is, to be able to observe elements and trigger events based on where they intersect the viewport.
That said, it’s worth using caution when making visual changes based on the intersection data obtained through the observer. Sure, Intersection Observer is hassle free when it comes to logging intersection data. But when it’s being used to make onscreen changes, we need to ensure the changes aren’t lagging, which is a possibility because we’re basically making changes based on data retrieved asynchronously. That might require a little bit of time to load.
As we saw, each intersection entry has a set of properties conveying information about the intersection. I didn’t cover all of them in this post, so be sure to review them.
Have you seen Diana Smith’s CSS drawings? Stunning. These far transcend the CSS drawings that sort of crudely replicate a flat SVG scene, like I might attempt. We were lucky enough for her to post some of her CSS drawing techniques here last year.
Well, Diana has also listed the top five CSS properties she uses to get these masterpieces done, and they are surprising in their plainness:
border-radius
box-shadow
transform
gradients
overflow
…but of course, layered in trickery!
… for custom rounded elements that are meant to mimic organic objects like faces, it is imperative that you become intimately familiar with all eight available parameters in the border-radius property.
Diana shows her famous Francine drawing with each of the most used properties turned off:
Without border-radiusWithout transform
Be sure to check out this VICE interview she did as well. She covers gems like the fact that Francine was inspired by American Dad (lol) and that the cross-browser fallbacks are both a fascinating and interesting mess.