Developer News
Invoker Commands: Additional Ways to Work With Dialog, Popover… and More?
The Popover API and <dialog> element are two of my favorite new platform features. In fact, I recently [wrote a detailed overview of their use cases] and the sorts of things you can do with them, even learning a few tricks in the process that I couldn’t find documented anywhere else.
I’ll admit that one thing that I really dislike about popovers and dialogs is that they could’ve easily been combined into a single API. They cover different use cases (notably, dialogs are typically modal) but are quite similar in practice, and yet their implementations are different.
Well, web browsers are now experimenting with two HTML attributes — technically, they’re called “invoker commands” — that are designed to invoke popovers, dialogs, and further down the line, all kinds of actions without writing JavaScript. Although, if you do reach for JavaScript, the new attributes — command and commandfor — come with some new events that we can listen for.
Invoker commands? I’m sure you have questions, so let’s dive in.
We’re in experimental territoryBefore we get into the weeds, we’re dealing with experimental features. To use invoker commands today in November 2024 you’ll need Chrome Canary 134+ with the enable-experimental-web-platform-features flag set to Enabled, Firefox Nightly 135+ with the dom.element.invokers.enabled flag set to true, or Safari Technology Preview with the InvokerAttributesEnabled flag set to true.
I’m optimistic we’ll get baseline coverage for command and commandfor in due time considering how nicely they abstract the kind of work that currently takes a hefty amount of scripting.
Basic command and commandfor usageFirst, you’ll need a <button> or a button-esque <input> along the lines of <input type="button"> or <input type="reset">. Next, tack on the command attribute. The command value should be the command name that you want the button to invoke (e.g., show-modal). After that, drop the commandfor attribute in there referencing the dialog or popover you’re targeting by its id.
<button command="show-modal" commandfor="dialogA">Show dialogA</button> <dialog id="dialogA">...</dialog>In this example, I have a <button> element with a command attribute set to show-modal and a commandfor attribute set to dialogA, which matches the id of a <dialog> element we’re targeting:
Let’s get into the possible values for these invoker commands and dissect what they’re doing.
Looking closer at the attribute values CodePen Embed FallbackThe show-modal value is the command that I just showed you in that last example. Specifically, it’s the HTML-invoked equivalent of JavaScript’s showModal() method.
The main benefit is that show-modal enables us to, well… show a modal without reaching directly for JavaScript. Yes, this is almost identical to how HTML-invoked popovers already work with thepopovertarget and popovertargetaction attributes, so it’s cool that the “balance is being redressed” as the Open UI explainer describes it, even more so because you can use the command and commandfor invoker commands for popovers too.
There isn’t a show command to invoke show() for creating non-modal dialogs. I’ve mentioned before that non-modal dialogs are redundant now that we have the Popover API, especially since popovers have ::backdrops and other dialog-like features. My bold prediction is that non-modal dialogs will be quietly phased out over time.
The close command is the HTML-invoked equivalent of JavaScript’s close() method used for closing the dialog. You probably could have guessed that based on the name alone!
<dialog id="dialogA"> <!-- Close #dialogA --> <button command="close" commandfor="dialogA">Close dialogA</button> </dialog> The show-popover, hide-popover, and toggle-popover values <button command="show-popover" commandfor="id">…invokes showPopover(), and is the same thing as:
<button popovertargetaction="show" popovertarget="id">Similarly:
<button command="hide-popover" commandfor="id">…invokes hidePopover(), and is the same thing as:
<button popovertargetaction="hide" popovertarget="id">Finally:
<button command="toggle-popover" commandfor="id">…invokes togglePopover(), and is the same thing as:
<button popovertargetaction="toggle" popovertarget="id"> <!-- or <button popovertarget="id">, since ‘toggle’ is the default action anyway. -->I know all of this can be tough to organize in your mind’s eye, so perhaps a table will help tie things together:
commandInvokespopovertargetaction equivalentshow-popovershowPopover()showhide-popoverhidePopover()hidetoggle-popovertogglePopover()toggleSo… yeah, popovers can already be invoked using HTML attributes, making command and commandfor not all that useful in this context. But like I said, invoker commands also come with some useful JavaScript stuff, so let’s dive into all of that.
Listening to commands with JavaScriptInvoker commands dispatch a command event to the target whenever their source button is clicked on, which we can listen for and work with in JavaScript. This isn’t required for a <dialog> element’s close event, or a popover attribute’s toggle or beforetoggle event, because we can already listen for those, right?
For example, the Dialog API doesn’t dispatch an event when a <dialog> is shown. So, let’s use invoker commands to listen for the command event instead, and then read event.command to take the appropriate action.
// Select all dialogs const dialogs = document.querySelectorAll("dialog"); // Loop all dialogs dialogs.forEach(dialog => { // Listen for close (as normal) dialog.addEventListener("close", () => { // Dialog was closed }); // Listen for command dialog.addEventListener("command", event => { // If command is show-modal if (event.command == "show-modal") { // Dialog was shown (modally) } // Another way to listen for close else if (event.command == "close") { // Dialog was closed } }); });So invoker commands give us additional ways to work with dialogs and popovers, and in some scenarios, they’ll be less verbose. In other scenarios though, they’ll be more verbose. Your approach should depend on what you need your dialogs and popovers to do.
For the sake of completeness, here’s an example for popovers, even though it’s largely the same:
// Select all popovers const popovers = document.querySelectorAll("[popover]"); // Loop all popovers popovers.forEach(popover => { // Listen for command popover.addEventListener("command", event => { // If command is show-popover if (event.command == "show-popover") { // Popover was shown } // If command is hide-popover else if (event.command == "hide-popover") { // Popover was hidden } // If command is toggle-popover else if (event.command == "toggle-popover") { // Popover was toggled } }); });Being able to listen for show-popover and hide-popover is useful as we otherwise have to write a sort of “if opened, do this, else do that” logic from within a toggle or beforetoggle event listener or toggle-popover conditional. But <dialog> elements? Yeah, those benefit more from the command and commandfor attributes than they do from this command JavaScript event.
Another thing that’s available to us via JavaScript is event.source, which is the button that invokes the popover or <dialog>:
if (event.command == "toggle-popover") { // Toggle the invoker’s class event.source.classList.toggle("active"); }You can also set the command and commandfor attributes using JavaScript:
const button = document.querySelector("button"); const dialog = document.querySelector("dialog"); button.command = "show-modal"; button.commandForElement = dialog; /* Not dialog.id */…which is only slightly less verbose than:
button.command = "show-modal"; button.setAttribute("commandfor", dialog.id); Creating custom commandsThe command attribute also accepts custom commands prefixed with two dashes (--). I suppose this makes them like CSS custom properties but for JavaScript events and event handler HTML attributes. The latter observation is maybe a bit (or definitely a lot) controversial since using event handler HTML attributes is considered bad practice. But let’s take a look at that anyway, shall we?
Custom commands look like this:
<button command="--spin-me-a-bit" commandfor="record">Spin me a bit</button> <button command="--spin-me-a-lot" commandfor="record">Spin me a lot</button> <button command="--spin-me-right-round" commandfor="record">Spin me right round</button> const record = document.querySelector("#record"); record.addEventListener("command", event => { if (event.command == "--spin-me-a-bit") { record.style.rotate = "90deg"; } else if (event.command == "--spin-me-a-lot") { record.style.rotate = "180deg"; } else if (event.command == "--spin-me-right-round") { record.style.rotate = "360deg"; } });event.command must match the string with the dashed (--) prefix.
Are popover and <dialog> the only features that support invoker commands?According to Open UI, invokers targeting additional elements such as <details> were deferred from the initial release. I think this is because HTML-invoked dialogs and an API that unifies dialogs and popovers is a must-have, whereas other commands (even custom commands) feel more like a nice-to-have deal.
However, based on experimentation (I couldn’t help myself!) web browsers have actually implemented additional invokers to varying degrees. For example, <details> commands work as expected whereas <select> commands match event.command (e.g., show-picker) but fail to actually invoke the method (showPicker()). I missed all of this at first because MDN only mentions dialog and popover.
Open UI also alludes to commands for <input type="file">, <input type="number">, <video>, <audio>, and fullscreen-related methods, but I don’t think that anything is certain at this point.
So, what would be the benefits of invoker commands?Well, a whole lot less JavaScript for one, especially if more invoker commands are implemented over time. Additionally, we can listen for these commands almost as if they were JavaScript events. But if nothing else, invoker commands simply provide more ways to interact with APIs such as the Dialog and Popover APIs. In a nutshell, it seems like a lot of “dotting i’s” and “crossing-t’s” which is never a bad thing.
Invoker Commands: Additional Ways to Work With Dialog, Popover… and More? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Complete CSS Course
Do you subscribe to Piccalilli? You should. If you’re reading that name for the first time, that would be none other than Andy Bell running the ship and he’s reimagined the site from the ground-up after coming out of hibernation this year. You’re likely familiar with Andy’s great writing here on CSS-Tricks.
Andy is more than a great writer — he’s a teacher, too. And you’ll see that in spades next week when his brand-new course Complete CSS is released one week from today on November 26.
As someone who also runs a front-end course, I can tell you it takes a non-trivial amount of time and effort to put something like Complete CSS together. I’ve been able to sneak peek at the course and like love how it’s made for many CSS-Tricks readers — you know CSS and use it regularly but need to ratchet it up from good to great. If my course is for those just getting into CSS, Andy will graduate you from hobbyist to practitioner in Complete CSS. It’s the perfect next step for narrowing the ever-growing learning gaps in this industry.
Early bird price is £189 (~$240) which is a steep cut from the full £249 (~$325) price tag.
Sign upComplete CSS Course originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Anchoreum: A New Game for Learning Anchor Positioning
You’ve played Flexbox Froggy before, right? Or maybe Grid Garden? They’re both absolute musts for learning the basics of modern CSS layout using Flexbox and CSS Grid. I use both games in all of the classes I teach and I never get anything but high-fives from my students because they love them so much.
As widely known as those games are, you may be less familiar with the name of the developer who made them. That would be Thomas Park, and he has a couple of CSS-Tricks articles notched in his belt. He also has a horde of other games in his CodePip collection of free and premium games for learning front-end techniques.
Thomas wrote in to share his latest game with us: Anchoreum.
I’ll bet the two nickels in my pocket that you know this game’s all about CSS Anchor Positioning. I love that Thomas has jumped on this so quickly because the feature is still fresh, and indeed is currently only supported in a couple of browsers at the moment.
This is the perfect time to learn about anchor positioning. It’s still relatively early days, but things are baked enough to be supported in Chrome and Edge so you can access the games. If you haven’t seen Juan’s big ol’ guide on anchor positioning, that’s another dandy way to get up to speed.
The objective is less on-the-nose than Flexbox Froggy and Grid Garden, which both lean heavily into positioning elements to complete game tasks. For example, Flexbox Froggy is about positioning frogs safely on lilypads. Grid Garden wants you to water specific garden areas to feed your carrots. Anchoreum? You’re in a museum and need to anchor labels to museum artifacts. I know, attaching target elements to the same anchor over and again could get boring. But thankfully the game goes beyond simple positioning by getting into multiple anchors, spanning, and position fallbacks.
Whatever the objective, the repetition is good for developing muscle memory and the overall outcome is still the same: learn CSS Anchor Positioning. I’m already planning how and where I’m going to use Anchoreum in my curriculum. It’s not often we get a fun interactive learning resource like this for such a new web feature and I think it’s worth jumping on it sooner rather than later.
Thomas prepped a video trailer for the game so I thought I’d drop that for reference.
Anchoreum: A New Game for Learning Anchor Positioning originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Tim Brown: Flexible Typesetting is now yours, for free
Another title from A Book Apart has been re-released for free. The latest? Tim Brown’s Flexible Typesetting. I may not be the utmost expert on typography and its best practices but I do remember reading this book (it’s still on the shelf next to me!) thinking maybe, just maybe, I might be able to hold a conversation about it with Robin when I finished it.
I still think I’m in “maybe” territory but that’s not Tim’s fault — I found the book super helpful and approachable for noobs like me who want to up our game. For the sake of it, I’ll drop the chapter titles here to give you an idea of what you’ll get.
- What is typsetting?
- Preparing text and code (planning is definitely part of the typesetting process)
- Selecting typefaces (this one helped me a lot!)
- Shaping text blocks (modern CSS can help here)
- Crafting compositions (great if you’re designing for long-form content)
- Relieving pressure
Tim Brown: Flexible Typesetting is now yours, for free originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The Different (and Modern) Ways to Toggle Content
If all you have is a hammer, everything looks like a nail.
Abraham MaslowIt’s easy to default to what you know. When it comes to toggling content, that might be reaching for display: none or opacity: 0 with some JavaScript sprinkled in. But the web is more “modern” today, so perhaps now is the right time to get a birds-eye view of the different ways to toggle content — which native APIs are actually supported now, their pros and cons, and some things about them that you might not know (such as any pseudo-elements and other non-obvious stuff).
So, let’s spend some time looking at disclosures (<details> and <summary>), the Dialog API, the Popover API, and more. We’ll look at the right time to use each one depending on your needs. Modal or non-modal? JavaScript or pure HTML/CSS? Not sure? Don’t worry, we’ll go into all that.
Disclosures (<details> and <summary>)Use case: Accessibly summarizing content while making the content details togglable independently, or as an accordion.
CodePen Embed FallbackGoing in release order, disclosures — known by their elements as <details> and <summary> — marked the first time we were able to toggle content without JavaScript or weird checkbox hacks. But lack of web browser support obviously holds new features back at first, and this one in particular came without keyboard accessibility. So I’d understand if you haven’t used it since it came to Chrome 12 way back in 2011. Out of sight, out of mind, right?
Here’s the low-down:
- It’s functional without JavaScript (without any compromises).
- It’s fully stylable without appearance: none or the like.
- You can hide the marker without non-standard pseudo-selectors.
- You can connect multiple disclosures to create an accordion.
- Aaaand… it’s fully animatable, as of 2024.
What you’re looking for is this:
<details> <summary>Content summary (always visible)</summary> Content (visibility is toggled when summary is clicked on) </details>Behind the scenes, the content’s wrapped in a pseudo-element that as of 2024 we can select using ::details-content. To add to this, there’s a ::marker pseudo-element that indicates whether the disclosure’s open or closed, which we can customize.
With that in mind, disclosures actually look like this under the hood:
<details> <summary><::marker></::marker>Content summary (always visible)</summary> <::details-content> Content (visibility is toggled when summary is clicked on) </::details-content> </details>To have the disclosure open by default, give <details> the open attribute, which is what happens behind the scenes when disclosures are opened anyway.
<details open> ... </details> Styling disclosuresLet’s be real: you probably just want to lose that annoying marker. Well, you can do that by setting the display property of <summary> to anything but list-item:
summary { display: block; /* Or anything else that isn't list-item */ } CodePen Embed FallbackAlternatively, you can modify the marker. In fact, the example below utilizes Font Awesome to replace it with another icon, but keep in mind that ::marker doesn’t support many properties. The most flexible workaround is to wrap the content of <summary> in an element and select it in CSS.
<details> <summary><span>Content summary</span></summary> Content </details> details { /* The marker */ summary::marker { content: "\f150"; font-family: "Font Awesome 6 Free"; } /* The marker when <details> is open */ &[open] summary::marker { content: "\f151"; } /* Because ::marker doesn’t support many properties */ summary span { margin-left: 1ch; display: inline-block; } } CodePen Embed Fallback Creating an accordion with multiple disclosures CodePen Embed FallbackTo create an accordion, name multiple disclosures (they don’t even have to be siblings) with a name attribute and a matching value (similar to how you’d implement <input type="radio">):
<details name="starWars" open> <summary>Prequels</summary> <ul> <li>Episode I: The Phantom Menace</li> <li>Episode II: Attack of the Clones</li> <li>Episode III: Revenge of the Sith</li> </ul> </details> <details name="starWars"> <summary>Originals</summary> <ul> <li>Episode IV: A New Hope</li> <li>Episode V: The Empire Strikes Back</li> <li>Episode VI: Return of the Jedi</li> </ul> </details> <details name="starWars"> <summary>Sequels</summary> <ul> <li>Episode VII: The Force Awakens</li> <li>Episode VIII: The Last Jedi</li> <li>Episode IX: The Rise of Skywalker</li> </ul> </details>Using a wrapper, we can even turn these into horizontal tabs:
CodePen Embed Fallback <div> <!-- Flex wrapper --> <details name="starWars" open> ... </details> <details name="starWars"> ... </details> <details name="starWars"> ... </details> </div> div { gap: 1ch; display: flex; position: relative; details { min-height: 106px; /* Prevents content shift */ &[open] summary, &[open]::details-content { background: #eee; } &[open]::details-content { left: 0; position: absolute; } } }…or, using 2024’s Anchor Positioning API, vertical tabs (same HTML):
div { display: inline-grid; anchor-name: --wrapper; details[open] { summary, &::details-content { background: #eee; } &::details-content { position: absolute; position-anchor: --wrapper; top: anchor(top); left: anchor(right); } } } CodePen Embed FallbackIf you’re looking for some wild ideas on what we can do with the Popover API in CSS, check out John Rhea’s article in which he makes an interactive game solely out of disclosures!
Adding JavaScript functionalityWant to add some JavaScript functionality?
// Optional: select and loop multiple disclosures document.querySelectorAll("details").forEach(details => { details.addEventListener("toggle", () => { // The disclosure was toggled if (details.open) { // The disclosure was opened } else { // The disclosure was closed } }); }); Creating accessible disclosuresDisclosures are accessible as long as you follow a few rules. For example, <summary> is basically a <label>, meaning that its content is announced by screen readers when in focus. If there isn’t a <summary> or <summary> isn’t a direct child of <details> then the user agent will create a label for you that normally says “Details” both visually and in assistive tech. Older web browsers might insist that it be the first child, so it’s best to make it so.
To add to this, <summary> has the role of button, so whatever’s invalid inside a <button> is also invalid inside a <summary>. This includes headings, so you can style a <summary> as a heading, but you can’t actually insert a heading into a <summary>.
The Dialog element (<dialog>)Use case: Modals
CodePen Embed FallbackNow that we have the Popover API for non-modal overlays, I think it’s best if we start to think of dialogs as modals even though the show() method does allow for non-modal dialogs. The advantage that the popover attribute has over the <dialog> element is that you can use it to create non-modal overlays without JavaScript, so in my opinion there’s no benefit to non-modal dialogs anymore, which do require JavaScript. For clarity, a modal is an overlay that makes the main document inert, whereas with non-modal overlays the main document remains interactive. There are a few other features that modal dialogs have out-of-the-box as well, including:
- a stylable backdrop,
- an autofocus onto the first focusable element within the <dialog> (or, as a backup, the <dialog> itself — include an aria-label in this case),
- a focus trap (as a result of the main document’s inertia),
- the esc key closes the dialog, and
- both the dialog and the backdrop are animatable.Marking up and activating dialogs
Start with the <dialog> element:
<dialog> ... </dialog>It’s hidden by default and, similar to <details>, we can have it open when the page loads, although it isn’t modal in this scenario since it does not contain interactive content because it doesn’t opened with showModal().
<dialog open> ... </dialog>I can’t say that I’ve ever needed this functionality. Instead, you’ll likely want to reveal the dialog upon some kind of interaction, such as the click of a button — so here’s that button:
<button data-dialog="dialogA">Open dialogA</button>Wait, why are we using data attributes? Well, because we might want to hand over an identifier that tells the JavaScript which dialog to open, enabling us to add the dialog functionality to all dialogs in one snippet, like this:
// Select and loop all elements with that data attribute document.querySelectorAll("[data-dialog]").forEach(button => { // Listen for interaction (click) button.addEventListener("click", () => { // Select the corresponding dialog const dialog = document.querySelector(`#${ button.dataset.dialog }`); // Open dialog dialog.showModal(); // Close dialog dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close()); }); });Don’t forget to add a matching id to the <dialog> so it’s associated with the <button> that shows it:
<dialog id="dialogA"> <!-- id and data-dialog = dialogA --> ... </dialog>And, lastly, include the “close” button:
<dialog id="dialogA"> <button class="closeDialog">Close dialogA</button> </dialog>Note: <form method="dialog"> (that has a <button>) or <button formmethod="dialog"> (wrapped in a <form>) also closes the dialog.
How to prevent scrolling when the dialog is openPrevent scrolling while the modal’s open, with one line of CSS:
body:has(dialog:modal) { overflow: hidden; } Styling the dialog’s backdropAnd finally, we have the backdrop to reduce distraction from what’s underneath the top layer (this applies to modals only). Its styles can be overwritten, like this:
::backdrop { background: hsl(0 0 0 / 90%); backdrop-filter: blur(3px); /* A fun property just for backdrops! */ }On that note, the <dialog> itself comes with a border, a background, and some padding, which you might want to reset. Actually, popovers behave the same way.
Dealing with non-modal dialogsTo implement a non-modal dialog, use:
- show() instead of showModal()
- dialog[open] (targets both) instead of dialog:modal
Although, as I said before, the Popover API doesn’t require JavaScript, so for non-modal overlays I think it’s best to use that.
The Popover API (<element popover>)Use case: Non-modal overlays
CodePen Embed FallbackPopups, basically. Suitable use cases include tooltips (or toggletips — it’s important to know the difference), onboarding walkthroughs, notifications, togglable navigations, and other non-modal overlays where you don’t want to lose access to the main document. Obviously these use cases are different to those of dialogs, but nonetheless popovers are extremely awesome. Functionally they’re just like just dialogs, but not modal and don’t require JavaScript.
Marking up popoversTo begin, the popover needs an id as well as the popover attribute with the manual value (which means clicking outside of the popover doesn’t close it), the auto value (clicking outside of the popover does close it), or no value (which means the same thing). To be semantic, the popover can be a <dialog>.
<dialog id="tooltipA" popover> ... </dialog>Next, add the popovertarget attribute to the <button> or <input type="button"> that we want to toggle the popover’s visibility, with a value matching the popover’s id attribute (this is optional since clicking outside of the popover will close it anyway, unless popover is set to manual):
<dialog id="tooltipA" popover> <button popovertarget="tooltipA">Hide tooltipA</button> </dialog>Place another one of those buttons in your main document, so that you can show the popover. That’s right, popovertarget is actually a toggle (unless you specify otherwise with the popovertargetaction attribute that accepts show, hide, or toggle as its value — more on that later).
Styling popovers CodePen Embed FallbackBy default, popovers are centered within the top layer (like dialogs), but you probably don’t want them there as they’re not modals, after all.
<main> <button popovertarget="tooltipA">Show tooltipA</button> </main> <dialog id="tooltipA" popover> <button popovertarget="tooltipA">Hide tooltipA</button> </dialog>You can easily pull them into a corner using fixed positioning, but for a tooltip-style popover you’d want it to be relative to the trigger that opens it. CSS Anchor Positioning makes this super easy:
main [popovertarget] { anchor-name: --trigger; } [popover] { margin: 0; position-anchor: --trigger; top: calc(anchor(bottom) + 10px); justify-self: anchor-center; } /* This also works but isn’t needed unless you’re using the display property [popover]:popover-open { ... } */The problem though is that you have to name all of these anchors, which is fine for a tabbed component but overkill for a website with quite a few tooltips. Luckily, we can match an id attribute on the button to an anchor attribute on the popover, which isn’t well-supported as of November 2024 but will do for this demo:
CodePen Embed Fallback <main> <!-- The id should match the anchor attribute --> <button id="anchorA" popovertarget="tooltipA">Show tooltipA</button> <button id="anchorB" popovertarget="tooltipB">Show tooltipB</button> </main> <dialog anchor="anchorA" id="tooltipA" popover> <button popovertarget="tooltipA">Hide tooltipA</button> </dialog> <dialog anchor="anchorB" id="tooltipB" popover> <button popovertarget="tooltipB">Hide tooltipB</button> </dialog> main [popovertarget] { anchor-name: --anchorA; } /* No longer needed */ [popover] { margin: 0; position-anchor: --anchorA; /* No longer needed */ top: calc(anchor(bottom) + 10px); justify-self: anchor-center; }The next issue is that we expect tooltips to show on hover and this doesn’t do that, which means that we need to use JavaScript. While this seems complicated considering that we can create tooltips much more easily using ::before/::after/content:, popovers allow HTML content (in which case our tooltips are actually toggletips by the way) whereas content: only accepts text.
Adding JavaScript functionalityWhich leads us to this…
CodePen Embed FallbackOkay, so let’s take a look at what’s happening here. First, we’re using anchor attributes to avoid writing a CSS block for each anchor element. Popovers are very HTML-focused, so let’s use anchor positioning in the same way. Secondly, we’re using JavaScript to show the popovers (showPopover()) on mouseover. And lastly, we’re using JavaScript to hide the popovers (hidePopover()) on mouseout, but not if they contain a link as obviously we want them to be clickable (in this scenario, we also don’t hide the button that hides the popover).
<main> <button id="anchorLink" popovertarget="tooltipLink">Open tooltipLink</button> <button id="anchorNoLink" popovertarget="tooltipNoLink">Open tooltipNoLink</button> </main> <dialog anchor="anchorLink" id="tooltipLink" popover>Has <a href="#">a link</a>, so we can’t hide it on mouseout <button popovertarget="tooltipLink">Hide tooltipLink manually</button> </dialog> <dialog anchor="anchorNoLink" id="tooltipNoLink" popover>Doesn’t have a link, so it’s fine to hide it on mouseout automatically <button popovertarget="tooltipNoLink">Hide tooltipNoLink</button> </dialog> [popover] { margin: 0; top: calc(anchor(bottom) + 10px); justify-self: anchor-center; /* No link? No button needed */ &:not(:has(a)) [popovertarget] { display: none; } } /* Select and loop all popover triggers */ document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => { /* Select the corresponding popover */ const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`); /* Show popover on trigger mouseover */ popovertarget.addEventListener("mouseover", () => { popover.showPopover(); }); /* Hide popover on trigger mouseout, but not if it has a link */ if (popover.matches(":not(:has(a))")) { popovertarget.addEventListener("mouseout", () => { popover.hidePopover(); }); } }); Implementing timed backdrops (and sequenced popovers)At first, I was sure that popovers having backdrops was an oversight, the argument being that they shouldn’t obscure a focusable main document. But maybe it’s okay for a couple of seconds as long as we can resume what we were doing without being forced to close anything? At least, I think this works well for a set of onboarding tips:
CodePen Embed Fallback <!-- Re-showing ‘A’ rolls the onboarding back to that step --> <button popovertarget="onboardingTipA" popovertargetaction="show">Restart onboarding</button> <!-- Hiding ‘A’ also hides subsequent tips as long as the popover attribute equates to auto --> <button popovertarget="onboardingTipA" popovertargetaction="hide">Cancel onboarding</button> <ul> <li id="toolA">Tool A</li> <li id="toolB">Tool B</li> <li id="toolC">Another tool, “C”</li> <li id="toolD">Another tool — let’s call this one “D”</li> </ul> <!-- onboardingTipA’s button triggers onboardingTipB --> <dialog anchor="toolA" id="onboardingTipA" popover> onboardingTipA <button popovertarget="onboardingTipB" popovertargetaction="show">Next tip</button> </dialog> <!-- onboardingTipB’s button triggers onboardingTipC --> <dialog anchor="toolB" id="onboardingTipB" popover> onboardingTipB <button popovertarget="onboardingTipC" popovertargetaction="show">Next tip</button> </dialog> <!-- onboardingTipC’s button triggers onboardingTipD --> <dialog anchor="toolC" id="onboardingTipC" popover> onboardingTipC <button popovertarget="onboardingTipD" popovertargetaction="show">Next tip</button> </dialog> <!-- onboardingTipD’s button hides onboardingTipA, which in-turn hides all tips --> <dialog anchor="toolD" id="onboardingTipD" popover> onboardingTipD <button popovertarget="onboardingTipA" popovertargetaction="hide">Finish onboarding</button> </dialog> ::backdrop { animation: 2s fadeInOut; } [popover] { margin: 0; align-self: anchor-center; left: calc(anchor(right) + 10px); } /* After users have had a couple of seconds to breathe, start the onboarding */ setTimeout(() => { document.querySelector("#onboardingTipA").showPopover(); }, 2000);Again, let’s unpack. Firstly, setTimeout() shows the first onboarding tip after two seconds. Secondly, a simple fade-in-fade-out background animation runs on the backdrop and all subsequent backdrops. The main document isn’t made inert and the backdrop doesn’t persist, so attention is diverted to the onboarding tips while not feeling invasive.
Thirdly, each popover has a button that triggers the next onboarding tip, which triggers another, and so on, chaining them to create a fully HTML onboarding flow. Typically, showing a popover closes other popovers, but this doesn’t appear to be the case if it’s triggered from within another popover. Also, re-showing a visible popover rolls the onboarding back to that step, and, hiding a popover hides it and all subsequent popovers — although that only appears to work when popover equates to auto. I don’t fully understand it but it’s enabled me to create “restart onboarding” and “cancel onboarding” buttons.
With just HTML. And you can cycle through the tips using esc and return.
Creating modal popoversHear me out. If you like the HTML-ness of popover but the semantic value of <dialog>, this JavaScript one-liner can make the main document inert, therefore making your popovers modal:
document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));However, the popovers must come after the main document; otherwise they’ll also become inert. Personally, this is what I’m doing for modals anyway, as they aren’t a part of the page’s content.
<body> <!-- All of this will become inert --> </body> <!-- Therefore, the modals must come after --> <dialog popover> ... </dialog> Aaaand… breatheYeah, that was a lot. But…I think it’s important to look at all of these APIs together now that they’re starting to mature, in order to really understand what they can, can’t, should, and shouldn’t be used for. As a parting gift, I’ll leave you with a transition-enabled version of each API:
- Sliding disclosures
- Popping dialog (with fading backdrop)
- Sliding popover (hamburger nav, because why not?)
The Different (and Modern) Ways to Toggle Content originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Popping Comments With CSS Anchor Positioning and View-Driven Animations
The State of CSS 2024 survey wrapped up and the results are interesting, as always. Even though each section is worth analyzing, we are usually most hyped about the section on the most used CSS features. And if you are interested in writing about web development (maybe start writing with us 😉), you will specifically want to check out the feature’s Reading List section. It holds the features that survey respondents wish to read about after completing the survey and is usually composed of up-and-coming features with low community awareness.
One of the features I was excited to see was my 2024 top pick: CSS Anchor Positioning, ranking in the survey’s Top 4. Just below, you can find Scroll-Driven Animations, another amazing feature that gained broad browser support this year. Both are elegant and offer good DX, but combining them opens up new possibilities that clearly fall into what most of us would have considered JavaScript territory just last year.
I want to show one of those possibilities while learning more about both features. Specifically, we will make the following blog post in which footnotes pop up as comments on the sides of each text.
CodePen Embed FallbackFor this demo, our requirements will be:
- Pop the footnotes up when they get into the screen.
- Attach them to their corresponding texts.
- The footnotes are on the sides of the screen, so we need a mobile fallback.
To start, we will use the following everyday example of a blog post layout: title, cover image, and body of text:
CodePen Embed FallbackThe only thing to notice about the markup is that now and then we have a paragraph with a footnote at the end:
<main class="post"> <!-- etc. --> <p class="note"> Super intereseting information! <span class="footnote"> A footnote about it </span> </p> </main> Positioning the FootnotesIn that demo, the footnotes are located inside the body of the post just after the text we want to note. However, we want them to be attached as floating bubbles on the side of the text. In the past, we would probably need a mix of absolute and relative positioning along with finding the correct inset properties for each footnote.
However, we can now use anchor positioning for the job, a feature that allows us to position absolute elements relative to other elements — rather than just relative to the containment context it is in. We will be talking about “anchors” and “targets” for a while, so a little terminology as we get going:
- Anchor: This is the element used as a reference for positioning other elements, hence the anchor name.
- Target: This is an absolutely-positioned element placed relative to one or more anchors. The target is the name we will use from now on, but you will often find it as just an “absolutely positioned element” in other resources.
I won’t get into each detail, but if you want to learn more about it I highly recommend our Anchor Positioning Guide for complete information and examples.
The Anchor and TargetIt’s easy to know that each .footnote is a target element. Picking our anchor, however, requires more nuance. While it may look like each .note element should be an anchor element, it’s better to choose the whole .post as the anchor. Let me explain if we set the .footnote position to absolute:
.footnote { position: absolute; }You will notice that the .footnote elements on the post are removed from the normal document flow and they hover visually above their .note elements. This is great news! Since they are already aligned on the vertical axis, we just have to move them on the horizontal axis onto the sides using the post as an anchor.
This is when we would need to find the correct inset property to place them on the sides. While this is doable, it’s a painful choice since:
- You would have to rely on a magic number.
- It depends on the viewport.
- It depends on the footnote’s content since it changes its width.
Elements aren’t anchors by default, so to register the post as an anchor, we have to use the anchor-name property and give it a dashed-ident (a custom name starting with two dashes) as a name.
.post { anchor-name: --post; }In this case, our target element would be the .footnote. To use a target element, we can keep the absolute positioning and select an anchor element using the position-anchor property, which takes the anchor’s dashed ident. This will make .post the default anchor for the target in the following step.
.footnote { position: absolute; position-anchor: --post; } Moving the Target AroundInstead of choosing an arbitrary inset value for the .footnote‘s left or right properties, we can use the anchor() function. It returns a <length> value with the position of one side of the anchor, allowing us to always set the target’s inset properties correctly. So, we can connect the left side of the target to the right side of the anchor and vice versa:
.footnote { position: absolute; position-anchor: --post; /* To place them on the right */ left: anchor(right); /* or to place them on the left*/ right: anchor(left); /* Just one of them at a time! */ }However, you will notice that it’s stuck to the side of the post with no space in between. Luckily, the margin property works just as you are hoping it does with target elements and gives a little space between the footnote target and the post anchor. We can also add a little more styles to make things prettier:
.footnote { /* ... */ background-color: #fff; border-radius: 20px; margin: 0px 20px; padding: 20px; }Lastly, all our .footnote elements are on the same side of the post, if we want to arrange them one on each side, we can use the nth-of-type() selector to select the even and odd notes and set them on opposite sides.
.note:nth-of-type(odd) .footnote { left: anchor(right); } .note:nth-of-type(even) .footnote { right: anchor(left); }We use nth-of-type() instead of nth-child since we just want to iterate over .note elements and not all the siblings.
Just remember to remove the last inset declaration from .footnote, and tada! We have our footnotes on each side. You will notice I also added a little triangle on each footnote, but that’s beyond the scope of this post:
CodePen Embed Fallback The View-Driven AnimationLet’s get into making the pop-up animation. I find it the easiest part since both view and scroll-driven animation are built to be as intuitive as possible. We will start by registering an animation using an everyday @keyframes. What we want is for our footnotes to start being invisible and slowly become bigger and visible:
@keyframes pop-up { from { opacity: 0; transform: scale(0.5); } to { opacity: 1; } }That’s our animation, now we just have to add it to each .footnote:
.footnote { /* ... */ animation: pop-up linear; }This by itself won’t do anything. We usually would have set an animation-duration for it to start. However, view-driven animations don’t run through a set time, rather the animation progression will depend on where the element is on the screen. To do so, we set the animation-timeline to view().
.footnote { /* ... */ animation: pop-up linear; animation-timeline: view(); }This makes the animation finish just as the element is leaving the screen. What we want is for it to finish somewhere more readable. The last touch is setting the animation-range to cover 0% cover 40%. This translates to, “I want the element to start its animation when it’s 0% in the view and end when it’s at 40% in the view.”
.footnote { /* ... */ animation: pop-up linear; animation-timeline: view(); animation-range: cover 0% cover 40%; }This amazing tool by Bramus focused on scroll and view-driven animation better shows how the animation-range property works.
What About Mobile?You may have noticed that this approach to footnotes doesn’t work on smaller screens since there is no space at the sides of the post. The fix is easy. What we want is for the footnotes to display as normal notes on small screens and as comments on larger screens, we can do that by making our comments only available when the screen is bigger than a certain threshold, which is about 1000px. If it isn’t, then the notes are displayed on the body of the post as any other note you may find on the web.
.footnote { display: flex; gap: 10px; border-radius: 20px; padding: 20px; background-color: #fce6c2; &::before { content: "Note:"; font-weight: 600; } } @media (width > 1000px) { /* Styles */ }Now our comments should be displayed on the sides only when there is enough space for them:
CodePen Embed Fallback Wrapping UpIf you also like writing about something you are passionate about, you will often find yourself going into random tangents or wanting to add a comment in each paragraph for extra context. At least, that’s my case, so having a way to dynamically show comments is a great addition. Especially when we achieved using only CSS — in a way that we couldn’t just a year ago!
Popping Comments With CSS Anchor Positioning and View-Driven Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Fluid Everything Else
We all know how to do responsive design, right? We use media queries. Well no, we use container queries now, don’t we? Sometimes we get inventive with flexbox or autoflowing grids. If we’re feeling really adventurous we can reach for fluid typography.
I’m a bit uncomfortable that responsive design is often pushed into discreet chunks, like “layout A up to this size, then layout B until there’s enough space for layout C.” It’s OK, it works and fits into a workflow where screens are designed as static layouts in PhotoFigVa (caveat, I made that up). But the process feels like a compromise to me. I’ve long believed that responsive design should be almost invisible to the user. When they visit my site on a mobile device while waiting in line for K-Pop tickets, they shouldn’t notice that it’s different from just an hour ago, sitting at the huge curved gaming monitor they persuaded their boss they needed.
Consider this simple hero banner and its mobile equivalent. Sorry for the unsophisticated design. The image is AI generated, but It’s the only thing about this article that is.
The meerkat and the text are all positioned and sized differently. The traditional way to pull this off is to have two layouts, selected by a media, sorry, container query. There might be some flexibility in each layout, perhaps centering the content, and a little fluid typography on the font-size, but we’re going to choose a point at which we flip the layout in and out of the stacked version. As a result, there are likely to be widths near the breakpoint where the layout looks either a little empty or a little congested.
Is there another way?
It turns out there is. We can apply the concept of fluid typography to almost anything. This way we can have a layout that fluidly changes with the size of its parent container. Few users will ever see the transition, but they will all appreciate the results. Honestly, they will.
Let’s get this styled upFor the first step, let’s style the layouts individually, a little like we would when using width queries and a breakpoint. In fact, let’s use a container query and a breakpoint together so that we can easily see what properties need to change.
This is the markup for our hero, and it won’t change:
<div id="hero"> <div class="details"> <h1>LookOut</h1> <p>Eagle Defense System</p> </div> </div>This is the relevant CSS for the wide version:
#hero { container-type: inline-size; max-width: 1200px; min-width: 360px; .details { position: absolute; z-index: 2; top: 220px; left: 565px; h1 { font-size: 5rem; } p { font-size: 2.5rem; } } &::before { content: ''; position: absolute; z-index: 1; top: 0; left: 0; right: 0; bottom: 0; background-image: url(../meerkat.jpg); background-origin: content-box; background-repeat: no-repeat; background-position-x: 0; background-position-y: 0; background-size: auto 589px; } }I’ve attached the background image to a ::before pseudo-element so I can use container queries on it (because containers cannot query themselves). We’ll keep this later on so that we can use inline container query (cqi) units. For now, here’s the container query that just shows the values we’re going to make fluid:
@container (max-width: 800px) { #hero { .details { top: 50px; left: 20px; h1 { font-size: 3.5rem; } p { font-size: 2rem; } } &::before { background-position-x: -310px; background-position-y: -25px; background-size: auto 710px; } } }You can see the code running in a live demo — it’s entirely static to show the limitations of a typical approach.
Let’s get fluidNow we can take those start and end points for the size and position of both the text and background and make them fluid. The text size uses fluid typography in a way you are already familiar with. Here’s the result — I’ll explain the expressions once you’ve looked at the code.
First the changes to the position and size of the text:
/* Line changes * -12,27 +12,32 */ .details { /* ... lines 14-16 unchanged */ /* Evaluates to 50px for a 360px wide container, and 220px for 1200px */ top: clamp(50px, 20.238cqi - 22.857px, 220px); /* Evaluates to 20px for a 360px wide container, and 565px for 1200px */ left: clamp(20px, 64.881cqi - 213.571px, 565px); /* ... lines 20-25 unchanged */ h1 { /* Evaluates to 3.5rem for a 360px wide container, and 5rem for 1200px */ font-size: clamp(3.5rem, 2.857rem + 2.857cqi, 5rem); /* ... font-weight unchanged */ } p { /* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */ font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem); } }And here’s the background position and size for the meerkat image:
/* Line changes * -50,3 +55,8 */ /* Evaluates to -310px for a 360px wide container, and 0px for 1200px */ background-position-x: clamp(-310px, 36.905cqi - 442.857px, 0px); /* Evaluates to -25px for a 360px wide container, and 0px for 1200px */ background-position-y: clamp(-25px, 2.976cqi); /* Evaluates to 710px for a 360px wide container, and 589px for 1200px */ background-size: auto clamp(589px, 761.857px - 14.405cqi, 710px);Now we can drop the container query entirely.
Let’s explain those clamp() expressions. We’ll start with the expression for the top property.
/* Evaluates to 50px for a 360px wide container, and 220px for 1200px */ top: clamp(50px, 20.238cqi - 22.857px, 220px);You’ll have noticed there’s a comment there. These expressions are a good example of how magic numbers are a bad thing. But we can’t avoid them here, as they are the result of solving some simultaneous equations — which CSS cannot do!
The upper and lower bounds passed to clamp() are clear enough, but the expression in the middle comes from these simultaneous equations:
f + 12v = 220 f + 3.6v = 50…where f is the number of fixed-size length units (i.e., px) and v is the variable-sized unit (cqi). In the first equation, we are saying that we want the expression to evaluate to 220px when 1cqi is equal to 12px. In the second equation, we’re saying we want 50px when 1cqi is 3.6px, which solves to:
f = -22.857 v = 20.238…and this tidies up to 20.238cqi – 22.857px in a calc()-friendly expression.
When the fixed unit is different, we must change the size of the variable units accordingly. So for the <h1> element’s font-size we have;
/* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */ font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);This is solving these equations because, at a container width of 1200px, 1cqi is the same as 0.75rem (my rems are relative to the default UA stylesheet, 16px), and at 360px wide, 1cqi is 0.225rem.
f + 0.75v = 2.5 f + 0.225v = 2This is important to note: The equations are different depending on what unit you are targeting.
Honestly, this is boring math to do every time, so I made a calculator you can use. Not only does it solve the equations for you (to three decimal places to keep your CSS clean) it also provides that helpful comment to use alongside the expression so that you can see where they came from and avoid magic numbers. Feel free to use it. Yes, there are many similar calculators out there, but they concentrate on typography, and so (rightly) fixate on rem units. You could probably port the JavaScript if you’re using a CSS preprocessor.
The clamp() function isn’t strictly necessary at this point. In each case, the bounds of clamp() are set to the values of when the container is either 360px or 1200px wide. Since the container itself is constrained to those limits — by setting min-width and max-width values — the clamp() expression should never invoke either bound. However, I prefer to keep clamp() there in case we ever change our minds (which we are about to do) because implicit bounds like these are difficult to spot and maintain.
Avoiding injuryWe could consider our work finished, but we aren’t. The layout still doesn’t quite work. The text passes right over the top of the meerkat’s head. While I have been assured this causes the meerkat no harm, I don’t like the look of it. So, let’s make some changes to make the text avoid hitting the meerkat.
The first is simple. We’ll move the meerkat to the left more quickly so that it gets out of the way. This is done most easily by changing the lower end of the interpolation to a wider container. We’ll set it so that the meerkat is fully left by 450px rather than down to 360px. There’s no reason the start and end points for all of our fluid expressions need to align with the same widths, so we can keep the other expressions fluid down to 360px.
Using my trusty calculator, all we need to do is change the clamp() expressions for the background-position properties:
/* Line changes * -55,5 +55,5 */ /* Evaluates to -310px for a 450px wide container, and 0px for 1200px */ background-position-x: clamp(-310px, 41.333cqi - 496px, 0px); /* Evaluates to -25px for a 450px wide container, and 0px for 1200px */ background-position-y: clamp(-25px, 3.333cqi - 40px, 0px);This improves things, but not totally. I don’t want to move it any quicker, so next we’ll look at the path the text takes. At the moment it moves in a straight line, like this:
But can we bend it? Yes, we can.
A Bend in the pathOne way we can do this is by defining two different interpolations for the top coordinate that places the line at different angles and then choosing the smallest one. This way, it allows the steeper line to “win” at larger container widths, and the shallower line becomes the value that wins when the container is narrower than about 780px. The result is a line with a bend that misses the meerkat.
All we’re changing is the top value, but we must calculate two intermediate values first:
/* Line changes * -18,2 +18,9 @@ */ /* Evaluates to 220px for a 1200px wide container, and -50px for 360px */ --top-a: calc(32.143cqi - 165.714px); /* Evaluates to 120px for a 1200px wide container, and 50px for 360px */ --top-b: calc(20px + 8.333cqi); /* By taking the max, --topA is used at lower widths, with --topB taking over when wider. We only need to apply clamp when the value is actually used */ top: clamp(50px, max(var(--top-a), var(--top-b)), 220px);For these values, rather than calculating them formally using a carefully chosen midpoint, I experimented with the endpoints until I got the result I wanted. Experimentation is just as valid as calculation as a way of getting the result you need. In this case, I started with duplicates of the interpolation in custom variables. I could have split the path into explicit sections using a container query, but that doesn’t reduce the math overhead, and using the min() function is cleaner to my eye. Besides, this article isn’t strictly about container queries, is it?
Now the text moves along this path. Open up the live demo to see it in action.
CSS can’t do everythingAs a final note on the calculations, it’s worth pointing out that there are restrictions as far as what we can and can’t do. The first, which we have already mitigated a little, is that these interpolations are linear. This means that easing in or out, or other complex behavior, is not possible.
Another major restriction is that CSS can only generate length values this way, so there is no way in pure CSS to apply, for example, opacity or a rotation angle that is fluid based on the container or viewport size. Preprocessors can’t help us here either because the limitation is on the way calc() works in the browser.
Both of these restrictions can be lifted if you’re prepared to rely on a little JavaScript. A few lines to observe the width of the container and set a CSS custom property that is unitless is all that’s needed. I’m going to use that to make the text follow a quadratic Bezier curve, like this:
There’s too much code to list here, and too much math to explain the Bezier curve, but go take a look at it in action in this live demo.
We wouldn’t even need JavaScript if expressions like calc(1vw / 1px) didn’t fail in CSS. There is no reason for them to fail since they represent a ratio between two lengths. Just as there are 2.54cm in 1in, there are 8px in 1vw when the viewport is 800px wide, so calc(1vw / 1px) should evaluate to a unitless 8 value.
They do fail though, so all we can do is state our case and move on.
Fluid everything doesn’t solve all layoutsThere will always be some layouts that need size queries, of course; some designs will simply need to snap changes at fixed breakpoints. There is no reason to avoid that if it’s right. There is also no reason to avoid mixing the two, for example, by fluidly sizing and positioning the background while using a query to snap between grid definitions for the text placement. My meerkat example is deliberately contrived to be simple for the sake of demonstration.
One thing I’ll add is that I’m rather excited by the possibility of using the new Anchor Positioning API for fluid positioning. There’s the possibility of using anchor positioning to define how two elements might flow around the screen together, but that’s for another time.
Fluid Everything Else originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations
We had fun in my previous article exploring the goodness of scrolly animations supported in today’s versions of Chrome and Edge (and behind a feature flag in Firefox for now). Those are by and large referred to as “scroll-driven” animations. However, “scroll triggering” is something the Chrome team is still working on. It refers to the behavior you might have seen in the wild in which a point of no return activates a complete animation like a trap after our hapless scrolling user ventures past a certain point. You can see JavaScript examples of this on the Wow.js homepage which assembles itself in a sequence of animated entrances as you scroll down. There is no current official CSS solution for scroll-triggered animations — but Ryan Mulligan has shown how we can make it work by cleverly combining the animation-timeline property with custom properties and style queries.
That is a very cool way to combine new CSS features. But I am not done being overly demanding toward the awesome emergent animation timeline technology I didn’t know existed before I read up on it last month. I noticed scroll timelines and view timelines are geared toward animations that play backward when you scroll back up, unlike the Wow.js example where the dogs roll in and then stay. Bramus mentions the same point in his exploration of scroll-triggered animations. The animations run in reverse when scrolling back up. This is not always feasible. As a divorced Dad, I can attest that the Tinder UI is another example of a pattern in which scrolling and swiping can have irreversible consequences.
Scroll till the cows come home with Web-Slinger.cssBelieve it or not, with a small amount of SCSS and no JavaScript, we can build a pure CSS replacement of the Wow.js library, which I hereby christen “Web-Slinger.css.” It feels good to use the scroll-driven optimized standards already supported by some major browsers to make a prototype library. Here’s the finished demo and then we will break down how it works. I have always enjoyed the deliberately lo-fi aesthetic of the original Wow.js page, so it’s nice to have an excuse to create a parody. Much profession, so impress.
CodePen Embed Fallback Teach scrolling elements to roll over and stayWeb-Slinger.css introduces a set of class names in the format .scroll-trigger-n and .on-scroll-trigger-n. It also defines --scroll-trigger-n custom properties, which are inherited from the document root so we can access them from any CSS class. These conventions are more verbose than Wow.js but also more powerful. The two types of CSS classes decouple the triggers of our one-off animations from the elements they trigger, which means we can animate anything on the page based on the user reaching any scroll marker.
Here’s a basic example that triggers the Animate.css animation “flipInY” when the user has scrolled to the <div> marked as .scroll-trigger-8.
<div class="scroll-trigger-8"></div> <img class="on-scroll-trigger-8 animate__animated animate__flipInY" src="https://i.imgur.com/wTWuv0U.jpeg" >A more advanced use is the sticky “Cownter” (trademark pending) at the top of the demo page, which takes advantage of the ability of one trigger to activate an arbitrary number of animations anywhere in the document. The Cownter increments as new cows appear then displays a reset button once we reach the final scroll trigger at the bottom of the page.
Here is the markup for the Cownter:
<div class="header"> <h2 class="cownter"></h2> <div class="animate__animated animate__backInDown on-scroll-trigger-12"> <br> <a href="#" class="reset">🔁 Play again</a> </div> </div>…and the CSS:
.header { .cownter::after { --cownter: calc(var(--scroll-trigger-2) + var(--scroll-trigger-4) + var(--scroll-trigger-8) + var(--scroll-trigger-11)); --pluralised-cow: 'cows'; counter-set: cownter var(--cownter); content: "Have " counter(cownter) " " var(--pluralised-cow) ", man"; } @container style(--scroll-trigger-2: 1) and style(--scroll-trigger-4: 0) { .cownter::after { --pluralised-cow: 'cow'; } } a { text-decoration: none; color:blue; } } :root:has(.reset:active) * { animation-name: none; }The demo CodePen references Web-Slinger.css from a separate CodePen, which I reference in my final demo the same way I would an external resource.
Sidenote: If you have doubts about the utility of style queries, behold the age-old cow pluralization problem solved in pure CSS.
How does Web Slinger like to sling it?The secret is based on an iconic thought experiment by the philosopher Friedrich Nietzsche who once asked: If the view() function lets you style an element once it comes into view, what if you take that opportunity to style it so it can never be scrolled out of view? Would that element not stare back into you for eternity?
.scroll-trigger { animation-timeline: view(); animation-name: stick-to-the-top; animation-fill-mode: both; animation-duration: 1ms; } @keyframes stick-to-the-top { .1%, to { position: fixed; top: 0; } }This idea sounded too good to be true, reminiscent of the urge when you meet a genie to ask for unlimited wishes. But it works! The next puzzle piece is how to use this one-way animation technique to control something we’d want to display to the user. Divs that instantly stick to the ceiling as soon as they enter the viewport might have their place on a page discussing the movie Alien, but most of the time this type of animation won’t be something we want the user to see.
That’s where named view progress timelines come in. The empty scroll trigger element only has the job of sticking to the top of the viewport as soon as it enters. Next, we set the timeline-scope property of the <body> element so that it matches the sticky element’s view-timeline-name. Now we can apply Ryan’s toggle custom property and style query tricks to let each sticky element trigger arbitrary one-off animations anywhere on the page!
View CSS code /** Each trigger element will cause a toggle named with * the convention `--scroll-trigger-n` to be flipped * from 0 to 1, which will unpause the animation on * any element with the class .on-scroll-trigger-n **/ :root { animation-name: run-scroll-trigger-1, run-scroll-trigger-2 /*etc*/; animation-duration: 1ms; animation-fill-mode: forwards; animation-timeline: --trigger-timeline-1, --trigger-timeline-2 /*etc*/; timeline-scope: --trigger-timeline-1, --trigger-timeline-2 /*etc*/; } @property --scroll-trigger-1 { syntax: "<integer>"; initial-value: 0; inherits: true; } @keyframes run-scroll-trigger-1 { to { --scroll-trigger-1: 1; } } /** Add this class to arbitrary elements we want * to only animate once `.scroll-trigger-1` has come * into view, default them to paused state otherwise **/ .on-scroll-trigger-1 { animation-play-state: paused; } /** The style query hack will run the animations on * the element once the toggle is set to true **/ @container style(--scroll-trigger-1: 1) { .on-scroll-trigger-1 { animation-play-state: running; } } /** The trigger element which sticks to the top of * the viewport and activates the one-way animation * that will unpause the animation on the * corresponding element marked with `.on-scroll-trigger-n` **/ .scroll-trigger-1 { view-timeline-name: --trigger-timeline-1; } Trigger warningWe generate the genericized Web-Slinger.css in 95 lines of SCSS, which isn’t too bad. The drawback is that the more triggers we need, the larger the compiled CSS file. The numbered CSS classes also aren’t semantic, so it would be great to have native support for linking a scroll-triggered element to its trigger based on IDs, reminiscent of the popovertarget attribute for HTML buttons — except this hypothetical attribute would go on each target element and specify the ID of the trigger, which is the opposite of the way popovertarget works.
<!-- This is speculative — do not use --> <scroll-trigger id="my-scroll-trigger"></scroll-trigger> <div class="rollIn" scrolltrigger="my-scroll-trigger">Hello world</div> Do androids dream of standardized scroll triggers?As I mentioned at the start, Bramus has teased that scroll-triggered animations are something we’d like to ship in a future version of Chrome, but it still needs a bit of work before we can do that. I’m looking forward to standardized scroll-triggered animations built into the browser. We could do worse than a convention resembling Web-Slinger.css for declaratively defining scroll-triggered animations, but I know I am not objective about Web Slinger as its creator. It’s become a bit of a sacred cow for me so I shall stop milking the topic — for now.
Feel free to reference the prototype Web-Slinger.css library in your experimental CodePens, or fork the library itself if you have better ideas about how scroll-triggered animations could be standardized.
Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
State of CSS 2024 Results
They’re out! Like many of you, I look forward to these coming out each year. I don’t put much stock in surveys but they can be insightful and give a snapshot of the CSS zeitgeist. There are a few little nuggets in this year’s results that I find interesting. But before I get there, you’ll want to also check out what others have already written about it.
- Josh Comeau digested his takeaways in a recent newsletter.
Oh, I guess that’s it — at least it’s the most formal write-up I’ve seen. There’s a little summary by Ahmad Shadeed at the end of the survey that generally rounds things up. I’ll drop in more links as I find ’em.
In no particular order…
DemographicsJosh has way more poignant thoughts on this than I do. He rightfully calls out discrepancies in gender pay and regional pay, where men are way more compensated than women (a nonsensical and frustratingly never-ending trend) and the United States boasts more $100,000 salaries than anywhere else. The countries with the highest salaries were also the most represented in survey responses, so perhaps the results are no surprise. We’re essentially looking at a snapshot of what it’s like to be a rich, white male developer in the West.
Besides pay, my eye caught the Age Group demographics. As an aging front-ender, I often wonder what we all do when we finally get to retirement age. I officially dropped from the most represented age group (30-39, 42%) a few years ago into the third most represented tier (40-49, 21%). Long gone are my days being with the cool kids (20-29, 27%).
And if the distribution is true to life, I’m riding fast into my sunset years and will be only slightly more represented than those getting into the profession. I don’t know if anyone else feels similarly anxious about aging in this industry — but if you’re one of the 484 folks who identify with the 50+ age group, I’d love to talk with you.
Before we plow ahead, I think it’s worth calling out how relatively “new” most people are to front-end development.
Wow! Forty-freaking-four percent of respondents have less than 10 years of experience. Yes, 10 years is a high threshold, but we’re still talking about a profession that popped up in recent memory.
For perspective, someone developing for 10 years came to the field around 2014. That’s just when we were getting Flexbox, and several years after the big bang of CSS 3 and HTML 5. That’s just under half of developers who never had to deal with the headaches of table layouts, clearfix hacks, image sprites, spacer images, and rasterized rounded corners. Ethan Marcotte’s seminal article on “Responsive Web Design” predates these folks by a whopping four years!
That’s just wild. And exciting. I’m a firm believer in the next generation of front-enders but always hope that they learn from our past mistakes and become masters at the basics.
FeaturesI’m not entirely sure what to make of this section. When there are so many CSS features, how do you determine which are most widely used? How do you pare it down to just 50 features? Like, are filter effects really the most widely used CSS feature? So many questions, but the results are always interesting nonetheless.
What I find most interesting are the underused features. For example, hanging-punctuation comes in dead last in usage (1.57%) but is the feature that most developers (52%) have on their reading list. (If you need some reading material on it, Chris initially published the Almanac entry for hanging-punctuation back in 2013.)
I also see Anchor Positioning at the end of the long tail with reported usage at 4.8%. That’ll go up for sure now that we have at least one supporting browser engine (Chromium) but also given all of the tutorials that have sprung up in the past few months. Yes, we’ve contributed to that noise… but it’s good noise! I think Juan published what might be the most thorough and thoughtful guide on the topic yet.
I’m excited to see Cascade Layers falling smack dab in the middle of the pack at a fairly robust 18.7%. Cascade Layers are super approachable and elegantly designed that I have trouble believing anybody these days when they say that the CSS Cascade is difficult to manage. And even though @scope is currently low on the list (4.8%, same as Anchor Positioning), I’d bet the crumpled gum wrapper in my pocket that the overall sentiment of working with the Cascade will improve dramatically. We’ll still see “CSS is Awesome” memes galore, but they’ll be more like old familiar dad jokes in good time.
(Aside: Did you see the proposed designs for a new CSS logo? You can vote on them as of yesterday, but earlier versions played off the “CSS is Awesome” mean quite beautifully.)
Interestingly enough, viewport units come in at Number 11 with 44.2% usage… which lands them at Number 2 for most experience that developers have with CSS layout. Does that suggest that layout features are less widely used than CSS filters? Again, so many questions.
FrameworksHow many of you were surprised that Tailwind blew past Bootstrap as Top Dog framework in CSS Land? Nobody, right?
More interesting to me is that “No CSS framework” clocks in at Number 13 out of 21 list frameworks. Sure, its 46 votes are dwarfed by the 138 for Material UI at Number 10… but the fact that we’re seeing “no framework” as a ranking option at all would have been unimaginable just three years ago.
The same goes for CSS pre/post-processing. Sass (67%) and PostCSS (38%) are the power players, but “None” comes in third at 19%, ahead of Less, Stylus, and Lightning CSS.
It’s a real testament to the great work the CSSWG is doing to make CSS better every day. We don’t thank the CSSWG enough — thank you, team! Y’all are heroes around these parts.
CSS UsageJosh already has a good take on the fact that only 67% of folks say they test their work on mobile phones. It should be at least tied with the 99% who test on desktops, right? Right?! Who knows, maybe some responses consider things like “Responsive Design Mode” desktop features to be the equivalent of testing on real mobile devices. I find it hard to believe that only 67% of us test mobile.
Oh, and The Great Divide is still alive and well if the results are true and 53% write more JavsScript than CSS in their day-to-day.
Missing CSS FeaturesThis is always a fun topic to ponder. Some of the most-wanted CSS features have been lurking around 10+ years. But let’s look at the top three form this year’s survey:
- Mixins
- Conditional Logic
- Masonry
We’re in luck team! There’s movement on all three of those fronts:
- A new CSS Functions and Mixins Module draft was published in late June after the CSSWG resolved to adopt the proposal back in February. (Read our notes.)
- The CSS Working Group (CSSWG) resolved to add an if() conditional to the CSS Values Module Level 5 specification. (Read our notes.)
- There are competing proposals for how to forge ahead with a CSS-y approach to masonry layouts. One is based on the CSS Grid Layout Module Level 3 draft specifcation and the other is a fresh new module dedicated to masonry. Apple has planted its flag. So has Chrome. Let the cage-match continue!
This is where I get to toot our own horn a bit because CSS-Tricks continues to place first among y’all when it comes to the blogs you follow for CSS happenings.
I’m also stoked to see Smashing Magazine right there as well. It was fifth in 2023 and I’d like to think that rise is due to me joining the team last year. Correlation implies causation, amirite?
But look at Kevin Powell and Josh in the Top 10. That’s just awesome. It speaks volumes about their teaching talents and the hard work they put into “helping people fall in love with CSS” as Kevin might say it. I was able to help Kevin with a couple of his videos last year (here’s one) and can tell you the guy cares a heckuva lot about making CSS approachable and fun.
Honestly, the rankings are not what we live for. Now that I’ve been given a second wind to work on CSS-Tricks, all I want is to publish things that are valuable to your everyday work as front-enders. That’s traditionally happened as a stream of daily articles but is shifting to more tutorials and resources, whether it’s guides (we’ve published four new ones this year), taking notes on interesting developments, spotlighting good work with links, or expanding the ol’ Almanac to account for things like functions, at-rules, and pseudos (we have lots of work to do).
My 2024 PickNo one asked my opinion but I’ll say it anyway: Personal blogging. I’m seeing more of us in the front-end community getting back behind the keyboards of their personal websites and I’ve never been subscribed to more RSS feeds than I am today. Some started blogging as a “worry stone” during the 2020 lockdown. Some abandoned socials when Twitter X imploded. Some got way into the IndieWeb. Webrings and guestbooks are even gaining new life. Sure, it can be tough keeping up, but what a good problem to have! Let’s make RSS king once and for all.
That’s a wrap!Seriously, a huge thanks to Sacha Greif and the entire Devographics team for the commitment to putting this survey together every year. It’s always fun. And the visualizations are always to die for.
State of CSS 2024 Results originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Tooltip Best Practices
In this article, I try to summarize the best practices mentioned by various accessibility experts and their work (like this, this, and this) into a single article that’s easy to read, understand, and apply.
Let’s begin.
What are tooltips?Tooltips are used to provide simple text hints for UI controls. Think of them as tips for tools. They’re basically little bubbles of text content that pop up when you hover over an unnamed control (like the bell icon in Stripe).
The “Notifications” text that pops up when you hover over Stripe’s bell icon is a tooltip.If you prefer more of a formal definition, Sarah Highley provides us with a pretty good one:
A “tooltip” is a non-modal (or non-blocking) overlay containing text-only content that provides supplemental information about an existing UI control. It is hidden by default, and becomes available on hover or focus of the control it describes.
She further goes on to say:
That definition could even be narrowed down even further by saying tooltips must provide only descriptive text.
This narrowed definition is basically (in my experience) how every accessibility expert defines tooltips:
- A tooltip is a popover.
- Tooltips must not contain interactive content.
Heydon Pickering takes things even further, saying: If you’re thinking of adding interactive content (even an ok button), you should be using dialog instead.
In his words:
You’re thinking of dialogs. Use a dialog.
Two kinds of tooltipsTooltips are basically only used for two things:
- Labeling an icon
- Providing a contextual description of an icon
Heydon separates these cleanly into two categories, “Primary Label” and “Auxiliary description” in his Inclusive Components article on tooltips and toggletips).
LabelingIf your tooltip is used to label an icon — using only one or two words — you should use the aria-labelledby attribute to properly label it since it is attached to nothing else on the page that would help identify it.
<button aria-labelledby="notifications"> ... </button> <div role="tooltip" id="notifications">Notifications</div>You could provide contextual information, like stating the number of notifications, by giving a space-separated list of ids to aria-labelledby.
<button aria-labelledby="notifications-count notifications-label"> <!-- bell icon here --> <span id="notifications-count">3</span> </button> <div role="tooltip" id="notifications-label">Notifications</div> Providing contextual descriptionIf your tooltip provides a contextual description of the icon, you should use aria-describedby. But, when you do this, you also need to provide an accessible name for the icon.
In this case, Heydon recommends including the label as the text content of the button. This label would be hidden visually from sighted users but read for screen readers.
Then, you can add aria-describedby to include the auxiliary description.
<button class="notifications" aria-describedby="notifications-desc"> <!-- icon for bell here --> <span id="notifications-count">3</span> <span class="visually-hidden">Notifications</span> </button> <div role="tooltip" id="notifications-desc">View and manage notifications settings</div>Here, screen readers would say “3 notifications” first, followed by “view and manage notifications settings” after a brief pause.
Additional tooltip dos and don’tsHere are a couple of additional points you should be aware of:
Do:
- Use aria-labellebdy or aria-describedby attributes depending on the type of tooltip you’re building.
- Use the tooltip role even if it doesn’t do much in screen readers today, because it may extend accessibility support for some software.
- Open tooltips on mouseover or focus, and close them on mouseout or blur.
- Allow a mouse user to move their mouse over the tooltip content without dismissing the tooltip.
- Allow a keyboard user to close the tooltip on the Escape button, per WCAG Success Criterion 1.4.13.
Don’t:
- Don’t use the title attribute. Much has been said about this so I shall not repeat them.
- Don’t use the aria-haspopup attribute with the tooltip role because aria-haspopup signifies interactive content while tooltip should contain non-interactive content.
- Don’t include essential content inside tooltips because some screen readers may ignore aria-labelledby or aria-describedby. (It’s rare, but possible.)
Tooltips are inaccessible to most touch devices because:
- users cannot hover over a button on a touch device, and
- users cannot focus on a button on a touch device.
The best alternative is not to use tooltips, and instead, find a way to include the label or descriptive text in the design.
If the “tooltip” contains a lot of content — including interactive content — you may want to display that information with a Toggletip (or just use a <dialog> element).
Heydon explains toggletips nicely and concisely:
Toggletips exist to reveal information balloons. Often they take the form of little “i” icons.
These informational icons should be wrapped within a <button> element. When opened, screen readers can announce the text contained in it through a live region with the status role.
<span class="tooltip-container"> <button type="button" aria-label="more info">i</button> <span role="status">This clarifies whatever needs clarifying</span> </span>Speaking anymore about toggletips detracts this article from tooltips so I’ll point you to Heydon’s “Tooltips and Toggletips” article if you’re interested in chasing this short rabbit hole.
That’s all you need to know about tooltips and their current best practices!
Further reading- Clarifying the Relationship Between Popovers and Dialogs (Zell Liew)
- Tooltips and Toggletips (Inclusive Components)
- Tooltips in the time of WCAG 2.1 (Sarah Higley)
- Short note on aria-label, aria-labelledby, and aria-describedby (Léonie Watson)
- Some Hands-On with the HTML Dialog Element (Chris Coyier)
Tooltip Best Practices originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Come to the light-dark() Side
You’d be forgiven for thinking coding up both a dark and a light mode at once is a lot of work. You have to remember @media queries based on prefers-color-scheme as well as extra complications that arise when letting visitors choose whether they want light or dark mode separately from the OS setting. And let’s not forget the color palette itself! Switching from a “light” mode to a “dark” mode may involve new variations to get the right amount of contrast for an accessible experience.
It is indeed a lot of work. But I’m here to tell you it’s now a lot simpler with modern CSS!
Default HTML color scheme(s)We all know the “naked” HTML theme even if we rarely see it as we’ve already applied a CSS reset or our favorite boilerplate CSS before we even open localhost. But here’s a news flash: HTML doesn’t only have the standard black-on-white theme, there is also a native white-on-black version.
We have two color schemes available to use right out of the box!If you want to create a dark mode interface, this is a great base to work with and saves you from having to account for annoying details, like dark inputs, buttons, and other interactive elements.
Live Demo on CodePen Switching color schemes automatically based on OS preferenceWithout any @media queries — or any other CSS at all — if all we did was declare color-scheme: light dark on the root element, the page will apply either the light or dark color scheme automatically by looking at the visitor’s operating system (OS) preferences. Most OSes have a built-in accessibility setting for your preferred color scheme — “light”, “dark”, or even “auto” — and browsers respect that setting.
html { color-scheme: light dark; }We can even accomplish this without CSS directly in the HTML document in a <meta> tag:
<meta name="color-scheme" content="light dark">Whether you go with CSS or the HTML route, it doesn’t matter — they both work the same way: telling the browser to make both light and dark schemes available and apply the one that matches the visitor’s preferences. We don’t even need to litter our styles with prefers-color-scheme instances simply to swap colors because the logic is built right in!
You can apply light or dark values to the color-scheme property. At the same time, I’d say that setting color-scheme: light is redundant, as this is the default color scheme with or without declaring it.
You can, of course, control the <meta> tag or the CSS property with JavaScript.
There’s also the possibility of applying the color-scheme property on specific elements instead of the entire page in one fell swoop. Then again, that means you are required to explicitly declare an element’s color and background-color properties; otherwise the element is transparent and inherits its text color from its parent element.
What values should you give it? Try:
Default text and background color variablesThe “black” colors of these native themes aren’t always completely black but are often off-black, making the contrast a little easier on the eyes. It’s worth noting, too, that there’s variation in the blackness of “black” between browsers.
What is very useful is that this default not-pure-black and maybe-not-pure-white background-color and text color are available as <system-color> variables. They also flip their color values automatically with color-scheme!
They are: Canvas and CanvasText.
These two variables can be used anywhere in your CSS to call up the current default background color (Canvas) or text color (CanvasText) based on the current color scheme. If you’re familiar with the currentColor value in CSS, it seems to function similarly. CanvasText, meanwhile, remains the default text color in that it can’t be changed the way currentColor changes when you assign something to color.
In the following examples, the only change is the color-scheme property:
Not bad! There are many, many more of these system variables. They are case-insensitive, often written in camelCase or PascalCase for readability. MDN lists 19 <system-color> variables and I’m dropping them in below for reference.
Open to view 19 system color names and descriptions- AccentColor: The background color for accented user interface controls
- AccentColorText: The text color for accented user interface controls
- ActiveText: The text color of active links
- ButtonBorder: The base border color for controls
- ButtonFace: The background color for controls
- ButtonText: The text color for controls
- Canvas: The background color of an application’s content or documents
- CanvasText: The text color used in an application’s content or documents
- Field: The background color for input fields
- FieldText: The text color inside form input fields
- GrayText: The text color for disabled items (e.g., a disabled control)
- Highlight: The background color for selected items
- HighlightText: The text color for selected items
- LinkText: The text color used for non-active, non-visited links
- Mark: The background color for text marked up in a <mark> element
- MarkText: The text color for text marked up in a <mark> element
- SelectedItem: The background color for selected items (e.g., a selected checkbox)
- SelectedItemText: The text color for selected items
- VisitedText: The text visited links
Cool, right? There are many of them! There are, unfortunately, also discrepancies as far as how these color keywords are used and rendered between different OSes and browsers. Even though “evergreen” browsers arguably support all of them, they don’t all actually match what they’re supposed to, and fail to flip with the CSS color-scheme property as they should.
Egor Kloos (also known as dutchcelt) is keeping an eye on the current status of system colors, including which ones exist and the browsers that support them, something he does as part of a classless CSS framework cleverly called system.css.
CodePen Embed Fallback Declaring colors for both modes togetherOK good, so now you have a page that auto-magically flips dark and light colors according to system preferences. Whether you choose to use these system colors or not is up to you. I just like to point out that “dark” doesn’t always have to mean pure “black” just as “light” doesn’t have to mean pure “white.” There are lots more colors to pair together!
But what’s the best or simplest way to declare colors so they work in both light and dark mode?
In my subjective reverse-best order:
Third place: Declare color opacityYou could keep all the same background colors in dark and light modes, but declare them with an opacity (i.e. rgb(128 0 0 / 0.5) or #80000080). Then they’ll have the Canvas color shine through.
It’s unusable in this way for text colors, and you may end up with somewhat muted colors. But it is a nice easy way to get some theming done fast. I did this for the code blocks on this old light and dark mode demo.
Second place: Use color-mix()Like this:
color-mix(in oklab, Canvas 75%, RebeccaPurple);Similar (but also different) to using opacity to mute a color is mixing colors in CSS. We can even mix the system color variables! For example, one of the colors can be either Canvas or CanvasText so that the background color always mixes with Canvas and the text color always mixes with CanvasText.
We now have the CSS color-mix() function to help us with this. The first argument in the function defines the color space where the color mixing happens. For example, we can tell the function that we are working in the OKLAB color space, which is a rectangular color space like sRGB making it ideal to mix with sRGB color values for predictable results. You can certainly mix colors from different color spaces — the OKLAB/sRGB combination happens to work for me in this instance.
The second and third arguments are the colors you want to mix, and in what proportion. Proportions are optional but expressed in percentages. Without declaring a proportion, the mix is an even 50%-50% split. If you add percentages for both colors and they don’t match up to 100%, it does a little math for you to prevent breakages.
The color-mix() approach is useful if you’re happy to keep the same hues and color saturations regardless of whether the mode is light or dark.
In this example, as you change the value of the hue slider, you’ll see color changes in the themed boxes, following the theme color but mixed with Canvas and CanvasText:
CodePen Embed FallbackYou may have noticed that I used OKLCH and HSL color spaces in that last example. You may also have noticed that the HSL-based theme color and the themed paragraph were a lot more “flashy” as you moved the hue slider.
I’ve declared colors using a polar color space, like HSL, for years, loving that you can easily take a hue and go up or down the saturation and lightness scales based on need. But, I concede that it’s problematic if you’re working with multiple hues while trying to achieve consistent perceived lightness and saturation across them all. It can be difficult to provide ample contrast across a spectrum of colors with HSL.
The OKLCH color space is also polar just like HSL, with the same benefits. You can pick your hue and use the chroma value (which is a bit like saturation in HSL) and the lightness scales accurately in the same way. Both OKLCH and OKLAB are designed to better match what our eyes perceive in terms of brightness and color compared to transitioning between colors in the sRGB space.
While these color spaces may not explicitly answer the age-old question, Is my blue the same as your blue? the colors are much more consistent and require less finicking when you decide to base your whole website’s palette on a different theme color. With these color spaces, the contrasts between the computed colors remain much the same.
First place (winner!): Use light-dark()Like this:
light-dark(lavender, saddlebrown);With the previous color-mix() example, if you choose a pale lavender in light mode, its dark mode counterpart is very dark lavender.
The light-dark() function, conversely, provides complete control. You might want that element to be pale lavender in light mode and a deep burnt sienna brown in dark mode. Why not? You can still use color-mix() within light-dark() if you like — declare the colors however you like, and gain much more fine-grained control over your colors.
Feel free to experiment in the following editable demo:
CodePen Embed FallbackUsing color-scheme: light dark; — or the corresponding meta tag in HTML on your page —is a prerequisite for the light-dark() function because it allows the function to respect a person’s system preference, or whichever single light or dark value you have set on color-scheme.
Another consideration is that light-dark() is newly available across browsers, with just over 80% coverage across all users at the time I’m writing this. So, you might consider including a fallback in your CSS for browsers that lack support for the function.
What makes using color-scheme and light-dark() better than using @media queries?@media queries have been excellent tools, but using them to query prefers-color-scheme only ever follows the preference set within the person’s operating system. This is fine until you (rightfully) want to offer the visitor more choices, decoupled from whether they prefer the UI on their device to be dark or light.
We’re already capable of doing that, of course. We’ve become used to a lot of jiggery-pokery with extra CSS classes, using duplicated styles, or employing custom properties to make it happen.
The joy of using color-scheme is threefold:
- It gives you the basic monochrome dark mode for free!
- It can natively do the mode switching based on OS mode preference.
- You can use JavaScript to toggle between light and dark mode, and the colors declared in the light-dark() functions will follow it.
Essentially, all we are doing is setting one of three options for whether the color-scheme is light, dark, or updates auto-matically.
I advise offering all three as discrete options, as it removes some complications for you! Any new visitor to the site will likely be in auto mode because accepting the visitor’s OS setting is the least jarring default state. You then give that person the choice to stay with that or swap it out for a different color scheme. This way, there’s no need to sniff out what mode someone prefers to, for example, display the correct icon on a toggle and make it perform the correct action. There is also no need to keep an event listener on prefers-color-scheme in case of changes — your color-scheme: light dark declaration in CSS handles that for you.
Adjusting color-scheme in pure CSSYes, this is totally possible! But the approach comes with a few caveats:
- You can’t use <button> — only radio inputs, or <options> in a <select> element.
- It only works on a per page basis, not per website, which means changes are lost on reload or refresh.
- The browser needs to support the :has() pseudo-selector. Most modern browsers do, but some folks using older devices might miss out on the experience.
This approach is almost alarmingly simple and is fantastic for a simple one-pager! Most of the heavy lifting is done with this:
/* default, or 'auto' */ html { color-scheme: light dark; } html:has([value="light"]:checked) { color-scheme: light; } html:has([value="dark"]:checked) { color-scheme: dark; }The second and third rulesets above look for an attribute called value on any element that has “light” or “dark” assigned to it, then change the color-scheme to match only if that element is :checked.
This approach is not very efficient if you have a huge page full of elements. In those cases, it’s better to be more specific. In the following two examples, the CSS selectors check for value only within an element containing id="mode-switcher".
html:has(#mode-switcher [value="light"]:checked) { color-scheme: light } /* Did you know you don't need the ";" for a one-liner? Now you do! */Using a <select> element:
CodePen Embed FallbackUsing <input type="radio">:
CodePen Embed FallbackWe could theoretically use checkboxes for this, but since checkboxes are not supposed to be used for mutually exclusive options, I won’t provide an example here. What happens in the case of more than one option being checked? The last matching CSS declaration wins (which is dark in the examples above).
Adjusting color-scheme in HTML with JavaScriptI subscribe to Jeremy Keith’s maxim when it comes to reaching for JavaScript:
JavaScript should only do what only JavaScript can do.
This is exactly that kind of situation.
If you want to allow visitors to change the color scheme using buttons, or you would like the option to be saved the next time the visitor comes to the site, then we do need at least some JavaScript. Rather than using the :has() pseudo-selector in CSS, we have a few alternative approaches for changing the color-scheme when we add JavaScript to the mix.
Using <meta> tagsIf you have set your color-scheme within a meta tag in the <head> of your HTML:
<meta name="color-scheme" content="light dark">…you might start by making a useful constant like so:
const colorScheme = document.querySelector('meta[name="color-scheme"]');And then you can manipulate that, assigning it light or dark as you see fit:
colorScheme.setAttribute("content", "light"); // to light mode colorScheme.setAttribute("content", "dark"); // to dark mode colorScheme.setAttribute("content", "light dark"); // to auto modeThis is a very similar approach to using <meta> tags but is different if you are setting the color-scheme property in CSS:
html { color-scheme: light dark; }Instead of setting a colorScheme constant as we just did in the last example with the <meta> tag, you might select the <html> element instead:
const html = document.querySelector('html');Now your manipulations look like this:
html.style.setProperty("color-scheme", "light"); // to light mode html.style.setProperty("color-scheme", "dark"); // to dark mode html.style.setProperty("color-scheme", "light dark"); // to auto modeI like to turn those manipulations into functions so that I can reuse them:
function switchAuto() { html.style.setProperty("color-scheme", "light dark"); } function switchLight() { html.style.setProperty("color-scheme", "light"); } function switchDark() { html.style.setProperty("color-scheme", "dark"); }Alternatively, you might like to stay as DRY as possible and do something like this:
function switchMode(mode) { html.style.setProperty("color-scheme", mode === "auto" ? "light dark" : mode); }The following demo shows how this JavaScript-based approach can be used with buttons, radio buttons, and a <select> element. Please note that not all of the controls are hooked up to update the UI — the demo would end up too complicated since there’s no world where all three types of controls would be used in the same UI!
CodePen Embed FallbackI opted to use onchange and onclick in the HTML elements mainly because I find them readable and neat. There’s nothing wrong with instead attaching a change event listener to your controls, especially if you need to trigger other actions when the options change. Using onclick on a button doesn’t only work for clicks, the button is still keyboard-focusable and can be triggered with Spacebar and Enter too, as usual.
Remembering the selection for repeat visitsThe biggest caveat to everything we’ve covered so far is that this only works once. In other words, once the visitor has left the site, we’re doing nothing to remember their color scheme preference. It would be a better user experience to store that preference and respect it anytime the visitor returns.
The Web Storage API is our go-to for this. And there are two available ways for us to store someone’s color scheme preference for future visits.
localStorageLocal storage saves values directly on the visitor’s device. This makes it a nice way to keep things off your server, as the stored data never expires, allowing us to call it anytime. That said, we’re prone to losing that data whenever the visitor clears cookies and cache and they’ll have to make a new selection that is freshly stored in localStorage.
You pick a key name and give it a value with .setItem():
localStorage.setItem("mode", "dark");The key and value are saved by the browser, and can be called up again for future visits:
const mode = localStorage.getItem("mode");You can then use the value stored in this key to apply the person’s preferred color scheme.
sessionStorageSession storage is thrown away as soon as a visitor browses away to another site or closes the current window/tab. However, the data we capture in sessionStorage persists while the visitor navigates between pages or views on the same domain.
It looks a lot like localStorage:
sessionStorage.setItem("mode", "dark"); const mode = sessionStorage.getItem("mode"); Which storage method should I use?Personally, I started with sessionStorage because I wanted my site to be as simple as possible, and to avoid anything that would trigger the need for a GDPR-compliant cookie banner if we were holding onto the person’s preference after their session ends. If most of your traffic comes from new visitors, then I suggest using sessionStorage to prevent having to do extra work on the GDPR side of things.
That said, if your traffic is mostly made up of people who return to the site again and again, then localStorage is likely a better approach. The convenience benefits your visitors, making it worth the GDPR work.
The following example shows the localStorage approach. Open it up in a new window or tab, pick a theme other than what’s set in your operating system’s preferences, close the window or tab, then re-open the demo in a new window or tab. Does the demo respect the color scheme you selected? It should!
CodePen Embed FallbackChoose the “Auto” option to go back to normal.
If you want to look more closely at what is going on, you can open up the developer tools in your browser (F12 for Windows, CTRL+ click and select “Inspect” for macOS). From there, go into the “Application” tab and locate https://cdpn.io in the list of items stored in localStorage. You should see the saved key (mode) and the value (dark or light). Then start clicking on the color scheme options again and watch the mode update in real-time.
AccessibilityCongratulations! If you have got this far, you are considering or already providing versions of your website that are more comfortable for different people to use.
For example:
- People with strong floaters in their eyes may prefer to use dark mode.
- People with astigmatism may be able to focus more easily in light mode.
So, providing both versions leaves fewer people straining their eyes to access the content.
Contrast levelsI want to include a small addendum to this provision of a light and dark mode. An easy temptation is to go full monochrome black-on-white or white-on-black. It’s striking and punchy! I get it. But that’s just it — striking and punchy can also trigger migraines for some people who do a lot better with lower contrasts.
Providing high contrast is great for the people who need it. Some visual impairments do make it impossible to focus and get a sharp image, and a high contrast level can help people to better make out the word shapes through a blur. Minimum contrast levels are important and should be exceeded.
Thankfully, alongside other media queries, we can also query prefers-contrast which accepts values for no-preference, more, less, or custom.
In the following example (which uses :has() and color-mix()), a <select> element is displayed to offer contrast settings. When “Low” is selected, a filter of contrast(75%) is placed across the page. When “High” is selected, CanvasText and Canvas are used unmixed for text color and background color:
CodePen Embed FallbackAdding a quick high and low contrast theme gives your visitors even more choice for their reading comfort. Look at that — now you have three contrast levels in both dark and light modes — six color schemes to choose from!
ARIA-pressedARIA stands for Accessible Rich Internet Applications and is designed for adding a bit of extra info where needed to screen readers and other assistive tech.
The words “where needed” do heavy lifting here. It has been said that, like apostrophes, no ARIA is better than bad ARIA. So, best practice is to avoid putting it everywhere. For the most part (with only a few exceptions) native HTML elements are good to go out of the box, especially if you put useful text in your buttons!
The little bit of ARIA I use in this demo is for adding the aria-pressed attribute to the buttons, as unlike a radio group or select element, it’s otherwise unclear to anyone which button is the “active” one, and ARIA helps nicely with this use case. Now a screen reader will announce both its accessible name and whether it is in a pressed or unpressed state along with a button.
Following is an example code snippet with all the ARIA code bolded — yes, suddenly there’s lots more! You may find more elegant (or DRY-er) ways to do this, but showing it this way first makes it more clear to demonstrate what’s happening.
Our buttons have ids, which we have used to target them with some more handy consts at the top. Each time we switch mode, we make the button’s aria-pressed value for the selected mode true, and the other two false:
const html = document.querySelector("html"); const mode = localStorage.getItem("mode"); const lightSwitch = document.querySelector('#lightSwitch'); const darkSwitch = document.querySelector('#darkSwitch'); const autoSwitch = document.querySelector('#autoSwitch'); if (mode === "light") switchLight(); if (mode === "dark") switchDark(); function switchAuto() { html.style.setProperty("color-scheme", "light dark"); localStorage.removeItem("mode"); lightSwitch.setAttribute("aria-pressed","false"); darkSwitch.setAttribute("aria-pressed","false"); autoSwitch.setAttribute("aria-pressed","true"); } function switchLight() { html.style.setProperty("color-scheme", "light"); localStorage.setItem("mode", "light"); lightSwitch.setAttribute("aria-pressed","true"); darkSwitch.setAttribute("aria-pressed","false"); autoSwitch.setAttribute("aria-pressed","false"); } function switchDark() { html.style.setProperty("color-scheme", "dark"); localStorage.setItem("mode", "dark"); lightSwitch.setAttribute("aria-pressed","false"); darkSwitch.setAttribute("aria-pressed","true"); autoSwitch.setAttribute("aria-pressed","false"); }On load, the buttons have a default setting, which is when the “Auto” mode button is active. Should there be any other mode in the localStorage, we pick it up immediately and run either switchLight() or switchDark(), both of which contain the aria-pressed changes relevant to that mode.
<button id="autoSwitch" aria-pressed="true" type="button" onclick="switchAuto()">Auto</button> <button id="lightSwitch" aria-pressed="false" type="button" onclick="switchLight()">Light</button> <button id="darkSwitch" aria-pressed="false" type="button" onclick="switchDark()">Dark</button>The last benefit of aria-pressed is that we can also target it for styling purposes:
button[aria-pressed="true"] { background-color: transparent; border-width: 2px; }Finally, we have a nice little button switcher, with its state clearly shown and announced, that remembers your choice when you come back to it. Done!
CodePen Embed Fallback OutroductionOr whatever the opposite of an introduction is…
…don’t let yourself get dragged into the old dark vs light mode argument. Both are good. Both are great! And both modes are now easy to create at once. At the start of your next project, work or hobby, do not give in to fear and pick a side — give both a try, and give in to choice.
Come to the light-dark() Side originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Left Half and Right Half Layout – Many Different Ways
A whole bunch of years ago, we posted on this idea here on CSS-Tricks. We figured it was time to update that and do the subject justice.
Imagine a scenario where you need to split a layout in half. Content on the left and content on the right. Basically two equal height columns are needed inside of a container. Each side takes up exactly half of the container, creating a distinct break between one. Like many things in CSS, there are a number of ways to go about this and we’re going to go over many of them right now!
Update (Oct. 25, 2024): Added an example that uses CSS Anchor Positioning.
Using Background GradientOne simple way we can create the appearance of a changing background is to use gradients. Half of the background is set to one color and the other half another color. Rather than fade from one color to another, a zero-space color stop is set in the middle.
.container { background: linear-gradient( to right, #ff9e2c 0%, #ff9e2c 50%, #b6701e 50%, #b6701e 100% ); }This works with a single container element. However, that also means that it will take working with floats or possibly some other layout method if content needs to fill both sides of the container.
CodePen Embed Fallback Using Absolute PositioningAnother route might be to set up two containers inside of a parent container, position them absolutely, split them up in halves using percentages, then apply the backgrounds. The benefit here is that now we have two separate containers that can hold their own content.
CodePen Embed FallbackAbsolute positioning is sometimes a perfect solution, and sometimes untenable. The parent container here will need to have a set height, and setting heights is often bad news for content (content changes!). Not to mention absolute positioned elements are out of the document flow. So it would be hard to get this to work while, say, pushing down other content below it.
Using (fake) TablesYeah, yeah, tables are so old school (not to mention fraught with accessibility issues and layout inflexibility). Well, using the display: table-cell; property can actually be a handy way to create this layout without writing table markup in HTML. In short, we turn our semantic parent container into a table, then the child containers into cells inside the table — all in CSS!
CodePen Embed FallbackYou could even change the display properties at breakpoints pretty easily here, making the sides stack on smaller screens. display: table; (and friends) is supported as far back as IE 8 and even old Android, so it’s pretty safe!
Using FloatsWe can use our good friend the float to arrange the containers beside each other. The benefit here is that it avoids absolute positioning (which as we noted, can be messy).
CodePen Embed FallbackIn this example, we’re explicitly setting heights to get them to be even. But you don’t really get that ability with floats by default. You could use the background gradient trick we already covered so they just look even. Or look at fancy negative margin tricks and the like.
Also, remember you may need to clear the floats on the parent element to keep the document flow happy.
Using Inline-BlockIf clearing elements after floats seems like a burden, then using display: inline-block is another option. The trick here is to make sure that the elements for the individual sides have no breaks or whitespace in between them in the HTML. Otherwise, that space will be rendered as a literal space and the second half will break and fall.
CodePen Embed FallbackAgain there is nothing about inline-block that helps us equalize the heights of the sides, so you’ll have to be explicit about that.
There are also other potential ways to deal with that spacing problem described above.
Using FlexboxFlexbox is a pretty fantastic way to do this, just note that it’s limited to IE 10 and up and you may need to get fancy with the prefixes and values to get the best support.
Using this method, we turn our parent container into a flexible box with the child containers taking up an equal share of the space. No need to set widths or heights! Flexbox just knows what to do, because the defaults are set up perfectly for this. For instance, flex-direction: row; and align-items: stretch; is what we’re after, but those are the defaults so we don’t have to set them. To make sure they are even though, setting flex: 1; on the sides is a good plan. That forces them to take up equal shares of the space.
CodePen Embed FallbackIn this demo we’re making the side flex containers as well, just for fun, to handle the vertical and horizontal centering.
Using Grid LayoutFor those living on the bleeding edge, the CSS Grid Layout technique is like the Flexbox and Table methods merged into one. In other words, a container is defined, then split into columns and cells which can be filled flexibly with child elements.
CodePen Embed Fallback CSS Anchor PositioningThis started rolling out in 2024 and we’re still waiting for full browser support. But we can use CSS Anchor Positioning to “attach” one element to another — even if those two elements are completely unrelated in the markup.
The idea is that we have one element that’s registered as an “anchor” and another element that’s the “target” of that anchor. It’s like the target element is pinned to the anchor. And we get to control where we pin it!
.anchor { anchor-name: --anchor; } .target { anchor-position: --anchor; position: absolute; /* required */ }This sets up an .anchor and establishes a relationship with a .target element. From here, we can tell the target which side of the anchor it should pin to.
.anchor { anchor-name: --anchor; } .target { anchor-position: --anchor; position: absolute; /* required */ left: anchor(right); } CodePen Embed FallbackIsn’t it cool how many ways there are to do things in CSS?
Left Half and Right Half Layout – Many Different Ways originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
You can use text-wrap: balance; on icons
Terence Eden on using text-wrap: balance for more than headings:
But the name is, I think, slightly misleading. It doesn’t only work on text. It will work on any content. For example – I have a row of icons at the bottom of this page. If the viewport is too narrow, a single icon might drop to the next line. That can look a bit weird.
Heck yeah. I may have reached for some sort of auto-fitting grid approach, but hey, may as well go with a one-liner if you can! And while we’re on the topic, I just wanna mention that, yes, text-wrap: balance will work on any content. — just know that the spec is a little opinionated on this and make sure that the content is fewer than five lines.
There’s likely more nuance to come if the note for Issue 6 in the spec is any indication about possibly allowing for a line length minimum:
Suggestion for value space is match-indent | <length> | <percentage> (with Xch given as an example to make that use case clear). Alternately <integer> could actually count the characters.
You can use text-wrap: balance; on icons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Clarifying the Relationship Between Popovers and Dialogs
The difference between Popovers (i.e., the popover attribute) and Dialogs (i.e., both the <dialog> element and the dialog accessible role) is incredibly confusing — so much that many articles (like this, this, and this) have tried to shed some light on the issue.
If you’re still feeling confused, I hope this one clears up that confusion once and for all.
Distinguishing Popovers From DialogsLet’s pull back on the technical implementations and consider the greater picture that makes more sense and puts everything into perspective.
The reason for this categorization comes from a couple of noteworthy points.
First, we know that a popover is content that “pops” up when a user clicks a button (or hovers over it, or focuses on it). In the ARIA world, there is a useful attribute called aria-haspopup that categorizes such popups into five different roles:
- menu
- listbox
- tree
- grid
- dialog
Strictly speaking, there’s a sixth value, true, that evaluates to menu. I didn’t include it above since it’s effectively just menu.
By virtue of dialog being on this list, we already know that dialog is a type of popover. But there’s more evidence behind this too.
The Three Types of DialoguesSince we’re already talking about the dialog role, let’s further expand that into its subcategories:
Dialogs can be categorized into three main kinds:
- Modal: A dialog with an overlay and focus trapping
- Non-Modal: A dialog with neither an overlay nor focus trapping
- Alert Dialog: A dialog that alerts screen readers when shown. It can be either modal or non-modal.
This brings us to another reason why a dialog is considered a popover.
Some people may say that popovers are strictly non-modal, but this seems to be a major misunderstanding — because popovers have a ::backdrop pseudo-element on the top layer. The presence of ::backdrop indicates that popovers are modal. Quoting the CSS-Tricks almanac:
The ::backdrop CSS pseudo-element creates a backdrop that covers the entire viewport and is rendered immediately below a <dialog>, an element with the popup attribute, or any element that enters fullscreen mode using the Fullscreen API.
That said, I don’t recommend using the Popover API for modality because it doesn’t have a showModal() method (that <dialog> has) that creates inertness, focus trapping, and other necessary features to make it a real modal. If you only use the Popover API, you’ll need to build those features from scratch.
So, the fact that popovers can be modal means that a dialog is simply one kind of popover.
A Popover Needs an Accessible RolePopovers need a role to be accessible. Hidde has a great article on selecting the right role, but I’m going to provide some points in this article as well.
To start, you can use one of the aria-haspopup roles mentioned above:
- menu
- listbox
- tree
- grid
- dialog
You could also use one of the more complex roles like:
- treegrid
- alertdialog
There are two additional roles that are slightly more contentious but may do just fine.
- tooltip
- status
To understand why tooltip and status could be valid popover roles, we need to take a detour into the world of tooltips.
A Note on TooltipsFrom a visual perspective, a tooltip is a popover because it contains a tiny window that pops up when the tooltip is displayed.
I included tooltip in the mental model because it is reasonable to implement tooltip with the Popover API.
<div popver role="tooltip">...</div>The tooltip role doesn’t do much in screen readers today so you need to use aria-describedby to create accessible tooltips. But it is still important because it may extend accessibility support for some software.
But, from an accessibility standpoint, tooltips are not popovers. In the accessibility world, tooltips must not contain interactive content. If they contain interactive content, you’re not looking at a tooltip, but a dialog.
You’re thinking of dialogs. Use a dialog.
Heydon Pickering, “Your Tooltips are Bogus”This is also why aria-haspopup doesn’t include tooltip —aria-haspopup is supposed to signify interactive content but a tooltip must not contain interactive content.
With that, let’s move on to status which is an interesting role that requires some explanation.
Why status?Tooltips have a pretty complex history in the world of accessible interfaces so there’s a lot of discussion and contention over it.
To keep things short (again), there’s an accessibility issue with tooltips since tooltips should only show on hover. This means screen readers and mobile phone users won’t be able to see those tooltips (since they can’t hover on the interface).
Steve Faulkner created an alternative — toggletips — to fill the gap. In doing so, he explained that toggletip content must be announced by screen readers through live regions.
When initially displayed content is announced by (most) screen readers that support aria-live
Heydon Pickering later added that status can be used in his article on toggletips.
We can supply an empty live region, and populate it with the toggletip “bubble” when it is invoked. This will both make the bubble appear visually and cause the live region to announce the tooltip’s information.
<!-- Code example by Heydon --> <span class="tooltip-container"> <button type="button" aria-label="more info" data-toggletip-content="This clarifies whatever needs clarifying">i</button> <span role="status"> <span class="toggletip-bubble">This clarifies whatever needs clarifying</span> </span> </span>This is why status can be a potential role for a popover, but you must use discretion when creating it.
That said, I’ve chosen not to include the status role in the Popover mental model because status is a live region role and hence different from the rest.
In SummaryHere’s a quick summary of the mental model:
- Popover is an umbrella term for any kind of on-demand popup.
- Dialog is one type of popover — a kind that creates a new window (or card) to contain some content.
When you internalize this, it’s not hard to see why the Popover API can be used with the dialog element.
<!-- Uses the popover API. Role needs to be determined manually --> <div popover>...</div> <!-- Dialog with the popover API. Role is dialog --> <dialog popover>...</dialog> <!-- Dialog that doesn't use the popover API. Role is dialog --> <dialog>...</dialog>When choosing a role for your popover, you can use one of these roles safely.
- menu
- listbox
- tree
- grid
- treegrid
- dialog
- alertdialog
The added benefit is most of these roles work together with aria-haspopup which gained decent support in screen readers last year.
Of course, there are a couple more you can use like status and tooltip, but you won’t be able to use them together with aria-haspopup.
Further Reading- aria-haspopup property (WAI-ARIA Specification, Version 1.2)
- Semantics and the popover attribute: which role to use when? (Hidde de Vries)
- aria-hasPopUp less is more (html5accessibility.com)
- Tooltips & Toggletips (Inclusive Components)
- What’s the Difference Between HTML’s Dialog Element and Popovers? (Chris Coyier)
Clarifying the Relationship Between Popovers and Dialogs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Clamp it! VS Code extension
There’s a lot of math behind fluid typography. CSS does make the math a lot easier these days, but even if you’re comfortable with that, writing the full declaration can be verbose and tough to remember. I know I often have to look it back up, despite having written it maybe a hundred times.
Silvestar made a little VS Code helper to abstract all that. Type in the target values you’re aiming for and the helper expands it on the spot.
He says ChatGPT did the initial lifting before he refined it. I can get behind this sort of AI-flavored usage. Start with an idea, find a starting point, look deeper at it, and shape it into something incredibly useful for a small, single purpose.
Clamp it! VS Code extension originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Unleash the Power of Scroll-Driven Animations
I’m utterly behind in learning about scroll-driven animations apart from the “reading progress bar” experiments all over CodePen. Well, I’m not exactly “green” on the topic; we’ve published a handful of articles on it including this neat-o one by Lee Meyer published the other week.
Our “oldest” article about the feature is by Bramus, dated back to July 2021. We were calling it “scroll-linked” animation back then. I specifically mention Bramus because there’s no one else working as hard as he is to discover practical use cases where scroll-driven animations shine while helping everyone understand the concept. He writes about it exhaustively on his personal blog in addition to writing the Chrome for Developers documentation on it.
But there’s also this free course he calls “Unleash the Power of Scroll-Driven Animations” published on YouTube as a series of 10 short videos. I decided it was high time to sit, watch, and learn from one of the best. These are my notes from it.
Introduction- A scroll-driven animation is an animation that responds to scrolling. There’s a direct link between scrolling progress and the animation’s progress.
- Scroll-driven animations are different than scroll-triggered animations, which execute on scroll and run in their entirety. Scroll-driven animations pause, play, and run with the direction of the scroll. It sounds to me like scroll-triggered animations are a lot like the CSS version of the JavaScript intersection observer that fires and plays independently of scroll.
- Why learn this? It’s super easy to take an existing CSS animation or a WAAPI animation and link it up to scrolling. The only “new” thing to learn is how to attach an animation to scrolling. Plus, hey, it’s the platform!
- There are also performance perks. JavsScript libraries that establish scroll-driven animations typically respond to scroll events on the main thread, which is render-blocking… and JANK! We’re working with hardware-accelerated animations… and NO JANK. Yuriko Hirota has a case study on the performance of scroll-driven animations published on the Chrome blog.
- Supported in Chrome 115+. Can use @supports (animation-timeline: scroll()). However, I recently saw Bramus publish an update saying we need to look for animation-range support as well.
- Remember to use prefers-reduced-motion and be mindful of those who may not want them.
Let’s take an existing CSS animation.
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } #progress { animation: grow-progress 2s linear forwards; }Translation: Start with no width and scale it to its full width. When applied, it takes two seconds to complete and moves with linear easing just in the forwards direction.
This just runs when the #progress element is rendered. Let’s attach it to scrolling.
- animation-timeline: The timeline that controls the animation’s progress.
- scroll(): Creates a new scroll timeline set up to track the nearest ancestor scroller in the block direction.
That’s it! We’re linked up. Now we can remove the animation-duration value from the mix (or set it to auto):
#progress { animation: grow-progress linear forwards; animation-timeline: scroll(); }Note that we’re unable to plop the animation-timeline property on the animation shorthand, at least for now. Bramus calls it a “reset-only sub-property of the shorthand” which is a new term to me. Its value gets reset when you use the shorthand the same way background-color is reset by background. That means the best practice is to declare animation-timeline after animation.
/* YEP! */ #progress { animation: grow-progress linear forwards; animation-timeline: scroll(); } /* NOPE! */ #progress { animation-timeline: scroll(); animation: grow-progress linear forwards; }Let’s talk about the scroll() function. It creates an anonymous scroll timeline that “walks up” the ancestor tree from the target element to the nearest ancestor scroll. In this example, the nearest ancestor scroll is the :root element, which is tracked in the block direction.
We can name scroll timelines, but that’s in another video. For now, know that we can adjust which axis to track and which scroller to target in the scroll() function.
animation-timeline: scroll(<axis> <scroller>);- <axis>: The axis — be it block (default), inline, y, or x.
- <scroller>: The scroll container element that defines the scroll position that influences the timeline’s progress, which can be nearest (default), root (the document), or self.
If the root element does not have an overflow, then the animation becomes inactive. WAAPI gives us a way to establish scroll timelines in JavaScript with ScrollTimeline.
const $progressbar = document.querySelector(#progress); $progressbar.style.transformOrigin = '0% 50%'; $progressbar.animate( { transform: ['scaleX(0)', 'scaleY()'], }, { fill: 'forwards', timeline: new ScrollTimeline({ source: document.documentElement, // root element // can control `axis` here as well }), } ) Video Two Core Concepts: view() and ViewTimelineFirst, we oughta distinguish a scroll container from a scroll port. Overflow can be visible or clipped. Clipped could be scrolling.
Those two bordered boxes show how easy it is to conflate scrollports and scroll containers. The scrollport is the visible part and coincides with the scroll container’s padding-box. When a scrollbar is present, that plus the scroll container is the root scroller, or the scroll container.
A view timeline tracks the relative position of a subject within a scrollport. Now we’re getting into IntersectionObserver territory! So, for example, we can begin an animation on the scroll timeline when an element intersects with another, such as the target element intersecting the viewport, then it progresses with scrolling.
Bramus walks through an example of animating images in long-form content when they intersect with the viewport. First, a CSS animation to reveal an image from zero opacity to full opacity (with some added clipping).
@keyframes reveal { from { opacity: 0; clip-path: inset(45% 20% 45% 20%); } to { opacity: 1; clip-path: inset(0% 0% 0% 0%); } } .revealing-image { animation: reveal 1s linear both; }This currently runs on the document’s timeline. In the last video, we used scroll() to register a scroll timeline. Now, let’s use the view() function to register a view timeline instead. This way, we’re responding to when a .revealing-image element is in, well, view.
.revealing-image { animation: reveal 1s linear both; /* Rember to declare the timeline after the shorthand */ animation-timeline: view(); }At this point, however, the animation is nice but only completes when the element fully exits the viewport, meaning we don’t get to see the entire thing. There’s a recommended way to fix this that Bramus will cover in another video. For now, we’re speeding up the keyframes instead by completing the animation at the 50% mark.
@keyframes reveal { from { opacity: 0; clip-path: inset(45% 20% 45% 20%); } 50% { opacity: 1; clip-path: inset(0% 0% 0% 0%); } }More on the view() function:
animation-timeline: view(<axis> <view-timeline-inset>);We know <axis> from the scroll() function — it’s the same deal. The <view-timeline-inset> is a way of adjusting the visibility range of the view progress (what a mouthful!) that we can set to auto (default) or a <length-percentage>. A positive inset moves in an outward adjustment while a negative value moves in an inward adjustment. And notice that there is no <scroller> argument — a view timeline always tracks its subject’s nearest ancestor scroll container.
OK, moving on to adjusting things with ViewTimeline in JavaScript instead.
const $images = document.querySelectorAll(.revealing-image); $images.forEach(($image) => { $image.animate( [ { opacity: 0, clipPath: 'inset(45% 20% 45% 20%)', offset: 0 } { opacity: 1; clipPath: 'inset(0% 0% 0% 0%)', offset: 0.5 } ], { fill: 'both', timeline: new ViewTimeline({ subject: $image, axis: 'block', // Do we have to do this if it's the default? }), } } )This has the same effect as the CSS-only approach with animation-timeline.
Video Three Timeline Ranges DemystifiedLast time, we adjusted where the image’s reveal animation ends by tweaking the keyframes to end at 50% rather than 100%. We could have played with the inset(). But there is an easier way: adjust the animation attachment range,
Most scroll animations go from zero scroll to 100% scroll. The animation-range property adjusts that:
animation-range: normal normal;Those two values: the start scroll and end scroll, default:
animation-range: 0% 100%;Other length units, of course:
animation-range: 100px 80vh;The example we’re looking at is a “full-height cover card to fixed header”. Mouthful! But it’s neat, going from an immersive full-page header to a thin, fixed header while scrolling down the page.
@keyframes sticky-header { from { background-position: 50% 0; height: 100vh; font-size: calc(4vw + 1em); } to { background-position: 50% 100%; height: 10vh; font-size: calc(4vw + 1em); background-color: #0b1584; } }If we run the animation during scroll, it takes the full animation range, 0%-100%.
.sticky-header { position: fixed; top: 0; animation: sticky-header linear forwards; animation-timeline: scroll(); }Like the revealing images from the last video, we want the animation range a little narrower to prevent the header from animating out of view. Last time, we adjusted the keyframes. This time, we’re going with the property approach:
.sticky-header { position: fixed; top: 0; animation: sticky-header linear forwards; animation-timeline: scroll(); animation-range: 0vh 90vh; }We had to subtract the full height (100vh) from the header’s eventual height (10vh) to get that 90vh value. I can’t believe this is happening in CSS and not JavaScript! Bramus sagely notes that font-size animation happens on the main thread — it is not hardware-accelerated — and the entire scroll-driven animation runs on the main as a result. Other properties cause this as well, notably custom properties.
Back to the animation range. It can be diagrammed like this:
The animation “cover range”. The dashed area represents the height of the animated target element.Notice that there are four points in there. We’ve only been chatting about the “start edge” and “end edge” up to this point, but the range covers a larger area in view timelines. So, this:
animation-range: 0% 100%; /* same as 'normal normal' */…to this:
animation-range: cover 0% cover 100%; /* 'cover normal cover normal' */…which is really this:
animation-range: cover;So, yeah. That revealing image animation from the last video? We could have done this, rather than fuss with the keyframes or insets:
animation-range: cover 0% cover 50%;So nice. The demo visualization is hosted at scroll-driven-animations.style. Oh, and we have keyword values available: contain, entry, exit, entry-crossing, and exit-crossing.
contain entry exitThe examples so far are based on the scroller being the root element. What about ranges that are taller than the scrollport subject? The ranges become slightly different.
Just have to be aware of the element’s size and how it impacts the scrollport.This is where the entry-crossing and entry-exit values come into play. This is a little mind-bendy at first, but I’m sure it’ll get easier with use. It’s clear things can get complex really quickly… which is especially true when we start working with multiple scroll-driven animation with their own animation ranges. Yes, that’s all possible. It’s all good as long as the ranges don’t overlap. Bramus uses a contact list demo where contact items animate when they enter and exit the scrollport.
@keyframes animate-in { 0% { opacity: 0; transform: translateY: 100%; } 100% { opacity: 1; transform: translateY: 0%; } } @keyframes animate-out { 0% { opacity: 1; transform: translateY: 0%; } 100% { opacity: 0; transform: translateY: 100%; } } .list-view li { animation: animate-in linear forwards, animate-out linear forwards; animation-timeline: view(); animation-range: entry, exit; /* animation-in, animation-out */ }Another way, using entry and exit keywords directly in the keyframes:
@keyframes animate-in { entry 0% { opacity: 0; transform: translateY: 100%; } entry 100% { opacity: 1; transform: translateY: 0%; } } @keyframes animate-out { exit 0% { opacity: 1; transform: translateY: 0%; } exit 100% { opacity: 0; transform: translateY: 100%; } } .list-view li { animation: animate-in linear forwards, animate-out linear forwards; animation-timeline: view(); }Notice that animation-range is no longer needed since its values are declared in the keyframes. Wow.
OK, ranges in JavaScript.:
const timeline = new ViewTimeline({ subjext: $li, axis: 'block', }) // Animate in $li.animate({ opacity: [ 0, 1 ], transform: [ 'translateY(100%)', 'translateY(0)' ], }, { fill: 'forwards', // One timeline instance with multiple ranges timeline, rangeStart: 'entry: 0%', rangeEnd: 'entry 100%', }) Video Four Core Concepts: Timeline Lookup and Named TimelinesThis time, we’re learning how to attach an animation to any scroll container on the page without needing to be an ancestor of that element. That’s all about named timelines.
But first, anonymous timelines track their nearest ancestor scroll container.
<html> <!-- scroll --> <body> <div class="wrapper"> <div style="animation-timeline: scroll();"></div> </div> </body> </html>Some problems might happen like when overflow is hidden from a container:
<html> <!-- scroll --> <body> <div class="wrapper" style="overflow: hidden;"> <!-- scroll --> <div style="animation-timeline: scroll();"></div> </div> </body> </html>Hiding overflow means that the element’s content block is clipped to its padding box and does not provide any scrolling interface. However, the content must still be scrollable programmatically meaning this is still a scroll container. That’s an easy gotcha if there ever was one! The better route is to use overflow: clip rather than hidden because that prevents the element from becoming a scroll container.
Hiding oveflow = scroll container. Clipping overflow = no scroll container. Bramus says he no longer sees any need to use overflow: hidden these days unless you explicitly need to set a scroll container. I might need to change my muscle memory to make that my go-to for hiding clipping overflow.
Another funky thing to watch for: absolute positioning on a scroll animation target in a relatively-positioned container. It will never match an outside scroll container that is scroll(inline-nearest) since it is absolute to its container like it’s unable to see out of it.
We don’t have to rely on the “nearest” scroll container or fuss with different overflow values. We can set which container to track with named timelines.
.gallery { position: relative; } .gallery__scrollcontainer { overflow-x: scroll; scroll-timeline-name: --gallery__scrollcontainer; scroll-timeline-axis: inline; /* container scrolls in the inline direction */ } .gallery__progress { position: absolute; animation: progress linear forwards; animation-timeline: scroll(inline nearest); }We can shorten that up with the scroll-timeline shorthand:
.gallery { position: relative; } .gallery__scrollcontainer { overflow-x: scroll; scroll-timeline: --gallery__scrollcontainer inline; } .gallery__progress { position: absolute; animation: progress linear forwards; animation-timeline: scroll(inline nearest); }Note that block is the scroll-timeline-axis initial value. Also, note that the named timeline is a dashed-ident, so it looks like a CSS variable.
That’s named scroll timelines. The same is true of named view timlines.
.scroll-container { view-timeline-name: --card; view-timeline-axis: inline; view-timeline-inset: auto; /* view-timeline: --card inline auto */ }Bramus showed a demo that recreates Apple’s old cover-flow pattern. It runs two animations, one for rotating images and one for setting an image’s z-index. We can attach both animations to the same view timeline. So, we go from tracking the nearest scroll container for each element in the scroll:
.covers li { view-timeline-name: --li-in-and-out-of-view; view-timeline-axis: inline; animation: adjust-z-index linear both; animation-timeline: view(inline); } .cards li > img { animation: rotate-cover linear both; animation-timeline: view(inline); }…and simply reference the same named timelines:
.covers li { view-timeline-name: --li-in-and-out-of-view; view-timeline-axis: inline; animation: adjust-z-index linear both; animation-timeline: --li-in-and-out-of-view;; } .cards li > img { animation: rotate-cover linear both; animation-timeline: --li-in-and-out-of-view;; }In this specific demo, the images rotate and scale but the updated sizing does not affect the view timeline: it stays the same size, respecting the original box size rather than flexing with the changes.
Phew, we have another tool for attaching animations to timelines that are not direct ancestors: timeline-scope.
timeline-scope: --example;This goes on an parent element that is shared by both the animated target and the animated timeline. This way, we can still attach them even if they are not direct ancestors.
<div style="timeline-scope: --gallery"> <div style="scroll-timeline: --gallery-inline;"> ... </div> <div style="animation-timeline: --gallery;"></div> </div>It accepts multiple comma-separated values:
timeline-scope: --one, --two, --three; /* or */ timeline-scope: all; /* Chrome 116+ */There’s no Safari or Firefox support for the all kewword just yet but we can watch for it at Caniuse (or the newer BCD Watch!).
This video is considered the last one in the series of “core concepts.” The next five are more focused on use cases and examples.
Video Five Add Scroll Shadows to a Scroll ContainerIn this example, we’re conditionally showing scroll shadows on a scroll container. Chris calls scroll shadows one his favorite CSS-Tricks of all time and we can nail them with scroll animations.
Here is the demo Chris put together a few years ago:
CodePen Embed FallbackThat relies on having a background with multiple CSS gradients that are pinned to the extremes with background-attachment: fixed on a single selector. Let’s modernize this, starting with a different approach using pseudos with sticky positioning:
.container::before, .container::after { content: ""; display: block; position: sticky; left: 0em; right 0em; height: 0.75rem; &::before { top: 0; background: radial-gradient(...); } &::after { bottom: 0; background: radial-gradient(...); } }The shadows fade in and out with a CSS animation:
@keyframes reveal { 0% { opacity: 0; } 100% { opacity: 1; } } .container { overflow:-y auto; scroll-timeline: --scroll-timeline block; /* do we need `block`? */ &::before, &::after { animation: reveal linear both; animation-timeline: --scroll-timeline; } }This example rocks a named timeline, but Bramus notes that an anonymous one would work here as well. Seems like anonymous timelines are somewhat fragile and named timelines are a good defensive strategy.
The next thing we need is to set the animation’s range so that each pseudo scrolls in where needed. Calculating the range from the top is fairly straightforward:
.container::before { animation-range: 1em 2em; }The bottom is a little tricker. It should start when there are 2em of scrolling and then only travel for 1em. We can simply reverse the animation and add a little calculation to set the range based on it’s bottom edge.
.container::after { animation-direction: reverse; animation-range: calc(100% - 2em) calc(100% - 1em); }Still one more thing. We only want the shadows to reveal when we’re in a scroll container. If, for example, the box is taller than the content, there is no scrolling, yet we get both shadows.
This is where the conditional part comes in. We can detect whether an element is scrollable and react to it. Bramus is talking about an animation keyword that’s new to me: detect-scroll.
@keyframes detect-scroll { from, to { --can-scroll: ; /* value is a single space and acts as boolean */ } } .container { animation: detect-scroll; animation-timeline: --scroll-timeline; animation-fill-mode: none; }Gonna have to wrap my head around this… but the general idea is that --can-scroll is a boolean value we can use to set visibility on the pseudos:
.content::before, .content::after { --vis-if-can-scroll: var(--can-scroll) visible; --vis-if-cant-scroll: hidden; visibility: var(--vis-if-can-scroll, var(--vis-if-cant-scroll)); }Bramus points to this CSS-Tricks article for more on the conditional toggle stuff.
Video Six Animate Elements in Different DirectionsThis should be fun! Let’s say we have a set of columns:
<div class="columns"> <div class="column reverse">...</div> <div class="column">...</div> <div class="column reverse">...</div> </div>The goal is getting the two outer reverse columns to scroll in the opposite direction as the inner column scrolls in the other direction. Classic JavaScript territory!
The columns are set up in a grid container. The columns flex in the column direction.
/* run if the browser supports it */ @supports (animation-timeline: scroll()) { .column-reverse { transform: translateY(calc(-100% + 100vh)); flex-direction: column-reverse; /* flows in reverse order */ } .columns { overflow-y: clip; /* not a scroll container! */ } }First, the outer columns are pushed all the way up so the bottom edges are aligned with the viewport’s top edge. Then, on scroll, the outer columns slide down until their top edges re aligned with the viewport’s bottom edge.
The CSS animation:
@keyframes adjust-position { from /* the top */ { transform: translateY(calc(-100% + 100vh)); } to /* the bottom */ { transform: translateY(calc(100% - 100vh)); } } .column-reverse { animation: adjust-position linear forwards; animation-timeline: scroll(root block); /* viewport in block direction */ }The approach is similar in JavaScript:
const timeline = new ScrollTimeline({ source: document.documentElement, }); document.querySelectorAll(".column-reverse").forEach($column) => { $column.animate( { transform: [ "translateY(calc(-100% + 100vh))", "translateY(calc(100% - 100vh))" ] }, { fill: "both", timeline, } ); } Video Seven Animate 3D Models and More on ScrollThis one’s working with a custom element for a 3D model:
<model-viewer alt="Robot" src="robot.glb"></model-viewer>First, the scroll-driven animation. We’re attaching an animation to the component but not defining the keyframes just yet.
@keyframes foo { } model-viewer { animation: foo linear both; animation-timeline: scroll(block root); /* root scroller in block direction */ }There’s some JavaScript for the full rotation and orientation:
// Bramus made a little helper for handling the requested animation frames import { trackProgress } from "https://esm.sh/@bramus/sda-utilities"; // Select the component const $model = document.QuerySelector("model-viewer"); // Animation begins with the first iteration const animation = $model.getAnimations()[0]; // Variable to get the animation's timing info let progress = animation.effect.getComputedTiming().progress * 1; // If when finished, $progress = 1 if (animation.playState === "finished") progress = 1; progress = Math.max(0.0, Math.min(1.0, progress)).toFixed(2); // Convert this to degrees $model.orientation = `0deg 0deg $(progress * -360)deg`;We’re using the effect to get the animation’s progress rather than the current timed spot. The current time value is always measured relative to the full range, so we need the effect to get the progress based on the applied animation.
Video Eight Scroll Velocity DetectionThe video description is helpful:
Bramus goes full experimental and uses Scroll-Driven Animations to detect the active scroll speed and the directionality of scroll. Detecting this allows you to style an element based on whether the user is scrolling (or not scrolling), the direction they are scrolling in, and the speed they are scrolling with … and this all using only CSS.
First off, this is a hack. What we’re looking at is expermental and not very performant. We want to detect the animations’s velocity and direction. We start with two custom properties.
@keyframes adjust-pos { from { --scroll-position: 0; --scroll-position-delayed: 0; } to { --scroll-position: 1; --scroll-position-delayed: 1; } } :root { animation: adjust-pos linear both; animation-timeline: scroll(root); }Let’s register those custom properties so we can interpolate the values:
@property --scroll-position { syntax: "<number>"; inherits: true; initial-value: 0; } @property --scroll-position-delayed { syntax: "<number>"; inherits: true; initial-value: 0; }As we scroll, those values change. If we add a little delay, then we can stagger things a bit:
:root { animation: adjust-pos linear both; animation-timeline: scroll(root); } body { transition: --scroll-position-delayed 0.15s linear; }The fact that we’re applying this to the body is part of the trick because it depends on the parent-child relationship between html and body. The parent element updates the values immediately while the child lags behind just a tad. The evaluate to the same value, but one is slower to start.
We can use the difference between the two values as they are staggered to get the velocity.
:root { animation: adjust-pos linear both; animation-timeline: scroll(root); } body { transition: --scroll-position-delayed 0.15s linear; --scroll-velocity: calc( var(--scroll-position) - var(--scroll-position-delayed) ); }Clever! If --scroll-velocity is equal to 0, then we know that the user is not scrolling because the two values are in sync. A positive number indicates the scroll direction is down, while a negative number indicates scrolling up,.
There’s a little discrepancy when scrolling abruptly changes direction. We can fix this by tighening the transition delay of --scroll-position-delayed but then we’re increasing the velocity. We might need a multiplier to further correct that… that’s why this is a hack. But now we have a way to sniff the scrolling speed and direction!
Here’s the hack using math functions:
body { transition: --scroll-position-delayed 0.15s linear; --scroll-velocity: calc( var(--scroll-position) - var(--scroll-position-delayed) ); --scroll-direction: sign(var(--scroll-velocity)); --scroll-speed: abs(var(--scroll-velocity)); }This is a little funny because I’m seeing that Chrome does not yet support sign() or abs(), at least at the time I’m watching this. Gotta enable chrome://flags. There’s a polyfill for the math brought to you by Ana Tudor right here on CSS-Tricks.
So, now we could theoretically do something like skew an element by a certain amount or give it a certain level of background color saturation depending on the scroll speed.
.box { transform: skew(calc(var(--scroll-velocity) * -25deg)); transition: background 0.15s ease; background: hsl( calc(0deg + (145deg * var(--scroll-direction))) 50 % 50% ); }We could do all this with style queries should we want to:
@container style(--scroll-direction: 0) { /* idle */ .slider-item { background: crimson; } } @container style(--scroll-direction: 1) { /* scrolling down */ .slider-item { background: forestgreen; } } @container style(--scroll-direction: -1) { /* scrolling down */ .slider-item { background: lightskyblue; } }Custom properties, scroll-driven animations, and style queries — all in one demo! These are wild times for CSS, tell ya what.
Video Nine OutroThe tenth and final video! Just a summary of the series, so no new notes here. But here’s a great demo to cap it off.
CodePen Embed Fallback Video TenUnleash the Power of Scroll-Driven Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Combining forces, GSAP & Webflow!
Change can certainly be scary whenever a beloved, independent software library becomes a part of a larger organization. I’m feeling a bit more excitement than concern this time around, though.
If you haven’t heard, GSAP (GreenSock Animation Platform) is teaming up with the visual website builder, Webflow. This mutually beneficial advancement not only brings GSAP’s powerful animation capabilities to Webflow’s graphical user interface but also provides the GSAP team the resources necessary to take development to the next level.
GSAP has been independent software for nearly 15 years (since the Flash and ActionScript days!) primarily supported by Club GSAP memberships, their paid tiers which offer even more tools and plugins to enhance GSAP further. GSAP is currently used on more than 12 million websites.
I chatted with Cassie Evans — GSAP’s Lead Bestower of Animation Superpowers and CSS-Tricks contributor — who confidently expressed that GSAP will remain available for the wider web.
It’s a big change, but we think it’s going to be a good one – more resources for the core library, more people maintaining the GSAP codebase, money for events and merch and community support, a VISUAL GUI in the pipeline.
The Webflow community has cause for celebration as well, as direct integration with GSAP has been a wishlist item for a while.
The webflow community is so lovely and creative and supportive and friendly too. It’s a good fit.
I’m so happy for Jack, Cassie, and Rodrigo, as well as super excited to see what happens next. If you don’t want to take my word for it, check out what Brody has to say about it.
Combining forces, GSAP & Webflow! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Mastering theme.json: You might not need CSS
I totally get the goal here: make CSS more modular and scalable in WordPress. Put all your global WordPress theme styles in a single file, including variations. JSON offers a nicely structured syntax that’s easily consumable by JavaScript, thereby allowing the sweet affordance of loading exactly what we want when we want it.
The problem, to me, is that writing “CSS” in a theme.json file is a complete mental model switcher-oo. Rather than selectors, we have a whole set of objects we have to know about just to select something. We have JSON properties that look and feel like CSS properties, only they have to be camelCased being JavaScript and all. And we’re configuring features in the middle of the styles, meaning we’ve lost a clear separation of concerns.
I’m playing devil’s advocate, of course. There’s a lot of upside to abstracting CSS with JSON for the very niche purpose of theming CMS templates and components. But after a decade of “CSS-in-JS is the Way™” I’m less inclined to buy into it. CSS is the bee’s knees just the way it is and I’m OK relying on it solely, whether it’s in the required style.css file or some other plain ol’ CSS file I generate. But that also means I’m losing out on the WordPress features that require you to write styles in a theme.json file, like style variations that can be toggled directly in the WordPress admin.
Regardless of all that, I’m linking this up because Justin does bang-up work (no surprise, really) explaining and illustrating the ways of CSS-in-WordPress. We have a complete guide that Ganesh rocked a couple of years ago. You might check that to get familiar with some terminology, jump into a nerdy deep dive on how WordPress generates classes from JSON, or just use the reference tables as a cheat sheet.
Mastering theme.json: You might not need CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Solving Background Overflow With Inherited Border Radii
One of the interesting (but annoying) things about CSS is the background of children’s elements can bleed out of the border radius of the parent element. Here’s an example of a card with an inner element. If the inner element is given a background, it can bleed out of the card’s border.
CodePen Embed FallbackThe easiest way to resolve this problem is to add overflow: hidden to the card element. I’m sure that’s the go-to solution most of us reach for when this happens.
But doing this creates a new problem — content outside the card element gets clipped off — so you can’t use negative margins or position: absolute to shift the children’s content out of the card.
CodePen Embed FallbackThere is a slightly more tedious — but more effective — way to prevent a child’s background from bleeding out of the parent’s border-radius. And that is to add the same border-radius to the child element.
The easiest way to do this is allowing the child to inherit the parent’s border-radius:
.child { border-radius: inherit; } CodePen Embed FallbackIf the border-radius shorthand is too much, you can still inherit the radius for each of the four corners on a case-by-case basis:
.child { border-top-left-radius: inherit; border-top-right-radius: inherit; border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; }Or, for those of you who’re willing to use logical properties, here’s the equivalent. (For an easier way to understand logical properties, replace top and left with start, and bottom and right with end.)
.child { border-start-start-radius: inherit; border-top-end-radius: inherit; border-end-start-radius: inherit; border-end-end-radius: inherit; } Can’t we just apply a background on the card?If you have a background directly on the .card that contains the border-radius, you will achieve the same effect. So, why not?
CodePen Embed FallbackWell, sometimes you can’t do that. One situation is when you have a .card that’s split into two, and only one part is colored in.
CodePen Embed Fallback So, why should we do this?Peace of mind is probably the best reason. At the very least, you know you won’t be creating problems down the road with the radius manipulation solution.
This pattern is going to be especially helpful when CSS Anchor Positioning gains full support. I expect that would become the norm popover positioning soon in about 1-2 years.
That said, for popovers, I personally prefer to move the popover content out of the document flow and into the <body> element as a direct descendant. By doing this, I prevent overflow: hidden from cutting off any of my popovers when I use anchor positioning.
Solving Background Overflow With Inherited Border Radii originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Close, Exit, Cancel: How to End User Interactions Well
What’s in a word? Actions. In the realm of user interfaces, a word is construed as the telltale of a control’s action. Sometimes it points us in the correct direction, and sometimes it leads us astray. We talk a lot about semantics in front-end web development, but outside of code, semantics are at the heart of copywriting where each word we convey can mean different things to different people. Words, if done right, add clarity and direction.
As a web user, I’ve come across words in user interfaces that have misled me. And not necessarily by design, either. Some words are synonymous with others and their true meaning depends entirely on context. Some words are easy to mistake for an unintended meaning because they are packed with so much meaning. A word might belong to a fellowship of interchangeable words.
Although I’m quite riled up when I misread content on a page — upset at the lack of clarity more than anything — as a developer, I can’t say I’ve always chosen the best possible words or combination of words for all the user interfaces I’ve ever made. But experience, both as a user and a developer, has elevated my commonsense when it comes to some of the literary choices I make while coding.
This article covers the words I choose for endings, to help users move away, and move on, without any confusion from the current process they are at on the screen. I went down this rabbit hole because I often find that ending something can mean many things — whether it be canceling an action, quitting an application, closing an element, navigating back, exiting a chat interaction… You get the idea. There are many ways to say that something is done, complete, and ready to move on to something else. I want to add clarity to that.
Getting CanceledIf there’s a Hall of Fame for button labels, this is the Babe Ruth of them all. “Cancel” is a widely used word to indicate an action that ends something. Cancel is a sharp, tenacious action. The person wants to bail on some process that didn’t go the way they expected it to. Maybe the page reveals a form that the person didn’t realize would be so long, so they want to back off. It could be something you have no control over whatsoever, like that person realizing they do not have their credit card information handy during checkout and they have to come back another time.
Cancel can feel personal at times, right? Don’t like the shipping costs calculated at checkout? Cancel the payment. Don’t like the newsletter? Cancel The Subscription. But really, the person only wants to undo an incorrect action or decision leaving no trace of it behind in favor of a clean slate to try again… or not.
The only times I feel betrayed by the word cancel is when the process I’m trying to end continues anyway. That comes up most when submitting forms with incorrect information. I enter something inadvertently, hit a big red Cancel button, yet the information I’ve “saved” persists to the extent that I either need to contact customer support or start looking for alternatives.
That’s the bottom line: Use “cancel” as an opportunity to confirm. It’s the person telling you, “Hey, that’s not actually what I meant to do,” and you get to step in and be the hero to wipe the mistake clean and set things up for a second chance. We’re not technically “ending” anything but rather starting clean and picking things back up for a better go. Think about that the next time you find yourself needing a label that encourages the user to try again. You might even consider synonyms that are less closely associated with closed endings, such as reset or retry.
Quitting or Exiting?Quit window, quit tab, quit app — now we’re talking about finality. When we “quit” or “exit” something, we’re changing course. We’ve made progress in one direction and decide it’s time to chart a different path. If we’re thinking about it in terms of freeway traffic, you might say that “quitting” is akin to pulling over and killing the engine, and “exiting” is taking leaving the freeway for another road. There’s a difference, although the two terms are closely related.
As far as we’re concerned as developers, quit and exit are hard stop points in an application. It’s been put to rest. Nothing else beyond this should be possible except its rebirth when the service is restarted or reopened. So, if your page is capable of nuking the current experience and the user takes it, then quit is the better label to make that point. We’re quitting and have no plans to restart or re-engage. If you were to “quit” your job, it’s not like your employer is expecting you to report for duty on Monday… or any other day for that matter.
But here’s my general advice about the word quit: only use it if you have to. I see very few use cases where we actually want to offer someone a true way to quit something. It’s so effective at conveying finality in web interfaces that it shuts the door on any future actions. For instance, I find that cancel often works in its place. And, personally, I find that saying “cancel payment” is more widely applicable. It’s softer and less rigid in the sense that it leaves the possibility to resume a process down the road.
Quit is also a simple process. Just clear everything and be gone. But if quitting means the user might lose some valuable data or progress, then that’s something they have to be warned about. In that case, exit and save may be better guidance.
I consider Exit the gentler twin of Quit. I prefer Quit just for the ultimatum of it. I see Exit used less frequently in interfaces than I see Quit. In rare cases, I might see Exit used specifically because of its softer nature to Quit even though “quitting” is the correct semantic choice given that the user really wants to wipe things clean and the assurance that nothing is left behind. Sometimes a “tougher” term is more reassuring.
Exit, however, is an excellent choice for actions that represent the end of human-to-human interactions — things like Exit Group, Exit Chat, Exit Streaming, Exit Class. If this person is kindly saying goodbye to someone or something but open to future interactions, allow them to exit when they’re done. They’re not quitting anything and we aren’t shoving them out the door.
Going Back (and Forth)Let’s talk about navigation. That’s the way we describe moving around the internet. We navigate from one place to another, to another, to another, and so on. It’s a journey of putting one digital foot in front of the other on the way to somewhere. That journey comes to an end when we get to our destination… or when we “quit” or “exit” the journey as we discussed above.
But the journey may take twists and turns. Not all movement is linear on the web. That’s why we often leave breadcrumbs in interfaces, right? It’s wayfinding on the web and provides people with a way to go “back” where they came from. Maybe that person forgot a step and needs to head back in order to move forward again.
In other words, back displaces people — laterally and hierarchically. Laterally, back (and its synonym, previous), backtracks across the same level in a process, for instance, between two sections of the same form, or two pages of the same document. Hierarchically, back — not to mention more explicit variants like “home” — is a level above that in the navigation hierarchy.
I like the explicit nature of saying something like “Home” when it comes to navigating someone “back” to a location or state. There’s no ambiguity there: hey, let’s go back home. Being explicit opens you up to more verbose labels but brevity isn’t always the goal. Even in iconography, adding more detail to a visual can help add clarity. The same is true with content in user interfaces. My favorite example is the classic “Back to Top” button on many pages that navigate you to the “top” of the page. We’re going “back to the top” which would not have been clear if we had used “Back” alone. Back where? That’s an important question — particularly when working with in-page anchors — and the answer may not be as obvious to others as it is to you. Communicating that level of hierarchy explicitly is a navigational feature.
While the “Back to Top” example I gave is a better illustration of lateral displacement than hierarchical displacement, I tend to avoid the label back with any sort of lateral navigation because moving laterally typically involves navigating between states more than navigating between pages. For example, the user may be navigating from a “logged in” state to a “logged out” state. In this case, I prefer being even more explicit — e.g., Save and Go Back, or Cancel and Go Home — than hierarchical navigation because we’re changing states on top of moving away from something.
Closing DownClose is yet another term you’ll find in the wild for conveying the “end” of something. It’s quite similar to Back in the sense that it serves dual purposes. It can be for navigation — close the current page and go back — or it can be for canceling an action — close the current page, and either discard or save all the data entered so far.
I prefer Close for neither of those cases. If we’re in the context of navigation, I like the clarity of the more explicit guidance we discussed above, e.g., Go Back, Previous, or Go Home. Giving someone an instruction to Close doesn’t say where that person is going to land once navigating away from the current page. And if we’re dealing with actions, Save and Close affirms the person that their data will be saved, rather than simply “closing” it out. If we were to simply say “cancel” instead, the insinuation is that the user is quitting the action and can expect to lose their work.
The one time I do feel that “Close” is the ideal label is working with pop-up dialogues and modals. Placing “Close” at the top-right (or the block-start, inline-end edge if we’re talking logical directions) corner is more than clear enough about what happens to the pop-up or modal when clicking it. We can afford to be a little less explicit with our semantics when someone’s focus is trapped in a specific context.
The End.I’ve saved the best for last, right? There’s no better way to connote an ending than simply calling it the “end”. It works well when we pair it with what’s ending.
End Chat. End Stream. End Webinar.
You’re terminating an established connection, not with a process, but with a human. And this is not some abrupt termination like Quit or Cancel. It’s more of a proper goodbye. Consider it also a synonym to Exit because the person ending the interaction may simply be taking a break. They’re not necessarily quitting something for good. Let’s leave the light on the front patio for them to return later and pick things back up..
And speaking of end, we’ve reached the end of this article. That’s the tricky, but liberating, thing about content semantics — some words may technically be correct but still mislead site visitors. It’s not that we’re ever trying to give someone bad directions, but it can still happen because this is a world where there are many ways of saying the same thing. Our goal is to be unambiguous and the milestone is clarity. Settling on the right word or combination of words takes effort. Anyone who has struggled with naming things in code knows about this. It’s the same for naming things outside of code.
I did not make an attempt to cover each and every word or way to convey endings. The point is that our words matter and we have all the choice and freedom in the world to find the best fit. But maybe you’ve recently run into a situation where you needed to “end” something and communicate that in an interface. Did you rely on something definitive and permanent (e.g. quit) or did you find that softer language (e.g. exit) was the better direction? What other synonyms did you consider? I’d love to know!
End Article.
Close, Exit, Cancel: How to End User Interactions Well originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.