Front End Web Development
The Little Triangle in the Tooltip
Tooltips are like homemade food: everyone uses them and everyone has their own recipe to make them. If you don’t remember a particular recipe, you will search for one, follow it, and go on with your day. This “many ways to do the same thing” concept is general to web development and programming (and life!), but it’s something that especially rings true with tooltips. There isn’t a specialized way to make them — and at this point, it isn’t needed — so people come up with different ways to fill those gaps.
Today, I want to focus on just one step of the recipe, which due to lack of a better name, I’ll just call the little triangle in the tooltip. It’s one of those things that receives minimal attention (admittedly, I didn’t know much before writing this) but it amazes you how many ways there are to make them. Let’s start with the simplest and make our way up to the not-so-simple.
Ideally, the tooltip is just one element. We want to avoid polluting our markup just for that little triangle:
<span class="tooltip">I am a tooltip</span> Clever borderBefore running, we have to learn to walk. And before connecting that little triangle we have to learn to make a triangle. Maybe the most widespread recipe for a triangle is the border trick, one that can be found in Stack Overflow issues from 2010 or even here by Chris in 2016.
In a nutshell, borders meet each other at 45° angles, so if an element has a border but no width and height, the borders will make four perfect triangles. What’s left is to set three border colors to transparent and only one triangle will show! You can find an animated version on this CodePen by Chris Coyier
CodePen Embed FallbackUsually, our little triangle will be a pseudo-element of the tooltip, so we need to set its dimensions to 0px (which is something ::before and ::after already do) and only set one of the borders to a solid color. We can control the size of the triangle base by making the other borders wider, and the height by making the visible border larger.
.tooltip { &::before { content: ""; border-width: var(--triangle-base); border-style: solid; border-color: transparent; border-top: var(--triangle-height) solid red; } }Attaching the triangle to its tooltip is an art in itself, so I am going with the basics and setting the little triangle’s position to absolute and the .tooltip to relative, then playing with its inset properties to place it where we want. The only thing to notice is that we will have to translate the little triangle to account for its width, -50% if we are setting its position with the left property, and 50% if we are using right.
.tooltip { position: relative; &::before { /* ... */ position: absolute; top: var(--triangle-top); left: var(--triangle-left); transform: translateX(-50%); } }However, we could even use the new Anchor Positioning properties for the task. Whichever method you choose, we should now have that little triangle attached to the tooltip:
CodePen Embed Fallback Rotated squareOne drawback from that last example is that we are blocking the border property so that if we need it for something else, we are out of luck. However, there is another old-school method to make that little triangle: we rotate a square by 45° degrees and hide half of it behind the tooltip’s body. This way, only the corner shows in the shape of a triangle. We can make the square out of a pseudo-element:
.tooltip { &::before { content: ""; display: block; height: var(--triangle-size); width: var(--triangle-size); background-color: red; } }Then, position it behind the tooltip’s body. In this case, such that only one-half shows. Since the square is rotated, the transformation will be on both axes.
.tooltip { position: relative; &::before { /* ... */ position: absolute; top: 75%; left: 50%; z-index: -1; /* So it's behind the tooltip's body */ transform: translateX(-50%); transform: rotate(45deg) translateY(25%) translateX(-50%); } } CodePen Embed FallbackI also found that this method works better with Anchor Positioning since we don’t have to change the little triangle’s styles whenever we move it around. Unlike the border method, in which the visible border changes depending on the direction.
CodePen Embed Fallback Trimming the square with clip-pathAlthough I didn’t mention it before, you may have noticed some problems with that last approach. First off, it isn’t exactly a triangle, so it isn’t the most bulletproof take; if the tooltip is too short, the square could sneak out on the top, and moving the false triangle to the sides reveals its true square nature. We can solve both issues using the clip-path property.
The clip-path property allows us to select a region of an element to display while clipping the rest. It works by providing the path we want to trim through, and since we want a triangle out of a square, we can use the polygon() function. It takes points in the element and trims through them in straight lines. The points can be written as percentages from the origin (i.e., top-left corner), and in this case, we want to trim through three points 0% 0% (top-left corner), 100% 0% (top-right corner) and 50% 100% (bottom-center point).
So, the clip-path value would be the polygon() function with those three points in a comma-separated list:
.tooltip { &::before { content: ""; width: var(--triangle-base); height: var(--triangle-height); clip-path: polygon(0% 0%, 100% 0%, 50% 100%); transform: translate(-50%); background-color: red; } }This time, we will set the top and left properties using CSS variables, which will come in handy later.
.tooltip { position: relative; &::before { /* ... */ position: absolute; top: var(--triangle-top); /* 100% */ left: var(--triangle-left); /* 50% */ transform: translate(-50%); } }And now we should have a true little triangle attached to the tooltip:
CodePen Embed FallbackHowever, if we take the little triangle to the far end of any side, we can still see how it slips out of the tooltip’s body. Luckily, the clip-path property gives us better control of the triangle’s shape. In this case, we can change the points the trim goes through depending on the horizontal position of the little triangle. For the top-left corner, we want its horizontal value to approach 50% when the tooltip’s position approaches 0%, while the top-right corner should approach 50% when the tooltip position approaches 100%.
The following min() + max() combo does exactly that:
.tooltip { clip-path: polygon( max(50% - var(--triangle-left), 0%) 0, min(150% - var(--triangle-left), 100%) 0%, 50% 100% ); }The calc() function isn’t necessary inside math functions like min() and max().
Try to move the tooltip around and see how its shape changes depending on where it is on the horizontal axis:
CodePen Embed Fallback Using the border-image propertyIt may look like our last little triangle is the ultimate triangle. However, imagine a situation where you have already used both pseudo-elements and can’t spare one for the little triangle, or simply put, you want a more elegant way of doing it without any pseudo-elements. The task may seem impossible, but we can use two properties for the job: the already-seen clip-path and the border-image property.
Using the clip-path property, we could trim the shape of a tooltip — with the little triangle included! — directly out of the element. The problem is that the element’s background isn’t big enough to account for the little triangle. However, we can use the border-image property to make an overgrown background. The syntax is a bit complex, so I recommend reading this full dive into border-image by Temani Afif. In short, it allows us to use an image or CSS gradient as the border of an element. In this case, we are making a border as wide as the triangle height and with a solid color.
.tooltip { border-image: fill 0 // var(--triangle-height) conic-gradient(red 0 0);; }The trim this time will be a little more complex, since we will also trim the little triangle, so more points are needed. Exactly, the following seven points:
This translates to the following clip-path value:
.tooltip { /* ... */ clip-path: polygon( 0% 100%, 0% 0%, 100% 0%, 100% 100%, calc(50% + var(--triangle-base) / 2) 100%, 50% calc(100% + var(--triangle-height)), calc(50% - var(--triangle-base) / 2) 100% ); }We can turn it smart by also capping the little triangle bottom point whenever it gets past any side of the tooltip:
.tooltip { /* ... */ clip-path: polygon( 0% 100%, 0% 0%, 100% 0%, 100% 100%, min(var(--triangle-left) + var(--triangle-base) / 2, 100%) 100%, var(--triangle-left) calc(100% + var(--triangle-height)), max(var(--triangle-left) - var(--triangle-base) / 2, 0%) 100% ; }And now we have our final little triangle of the tooltip, one that is part of the main body and only uses one element!
CodePen Embed Fallback More information- The Complex But Awesome CSS border-image Property (Temani Afif)
- Transforming Borders into Cool CSS Triangles (Optimistic Web)
The Little Triangle in the Tooltip originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
What ELSE is on your CSS wishlist?
What else do we want or need CSS to do? It’s like being out late at night someplace you shouldn’t be and a stranger in a trenchcoat walks up and whispers in your ear.
“Psst. You wanna buy some async @imports? I’ve got the specificity you want.”
You know you shouldn’t entertain the idea but you do it anyway. All your friends doing Cascade Layers. What are you, a square?
I keep thinking of how amazing it is to write CSS today. There was an email exchange just this morning where I was discussing a bunch of ideas for a persistent set of controls in the UI that would have sounded bonkers even one year ago if it wasn’t for new features, like anchor positioning, scroll timelines, auto-height transitions, and popovers. We’re still in the early days of all these things — among many, many more — and have yet to see all the awesome possibilities come to fruition. Exciting times!
Chris kept a CSS wishlist, going back as far as 2013 and following up on it in 2019. We all have things we’d like to see CSS do and we always will no matter how many sparkly new features we get. Let’s revisit the ones from 2013:
- ✅ “I’d like to be able to select an element based on if it contains another particular selector.” Hello, :has()!
- ❌ “I’d like to be able to select an element based on the content it contains.”
- ❌ “I’d like multiple pseudo-elements.”
- ✅ “I’d like to be able to animate/transition something to height: auto;” Yep, we got that!
- 🟠 “I’d like things from Sass, like @extend, @mixin, and nesting.” We got the nesting part down with some progress on mixins.
- ❌ “I’d like ::nth-letter, ::nth-word, etc.”
- ✅ “I’d like all the major browsers to auto-update.” This one was already fulfilled.
So, about a score of 3.5 out of 7. It could very well be that some of these things fell out of favor at some point (haven’t heard any crying for a new pseudo-element since the first wishlist). Chris re-articulated the list this way:
- Parent queries. As in, selecting an element any-which-way, then selecting the parent of that element. We have some proof it’s possible with :focus-within.
- Container queries. Select a particular element when the element itself is under certain conditions.
- Standardized styling of form elements.
- Has/Contains Selectors.
- Transitions to auto dimensions.
- Fixed up handling of viewport units.
And we’ve got the vast majority of those under wraps! We have ways to query parents and containers. We’re exploring stylable selects and field-sizing. We know about :has() and we’re still going gaga over transitions to intrinsic sizes. We’ve openly opined whether there’s too much CSS (there isn’t).
But what else is on your CSS wishlist? Ironically enough, Adam Argyle went through this exercise just this morning and I love the way he’s broken things down into a user-facing wishlist and a developer-facing wishlist. I mean, geez, a CSS carousel? Yes, please! I love his list and all lists like it.
We’ll round things up and put a list together — so let us know!
What ELSE is on your CSS wishlist? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Fluid Superscripts and Subscripts
Superscripts and subscripts are essential elements in academic and scientific content — from citation references to chemical formulas and mathematical expressions. Yet browsers handle these elements with a static approach that can create significant problems: elements become either too small on mobile devices or disproportionately large on desktop displays.
After years of wrestling with superscript and subscript scaling in CSS, I’m proposing a modern solution using fluid calculations. In this article, I’ll show you why the static approach falls short and how we can provide better typography across all viewports while maintaining accessibility. Best of all, this solution requires nothing but clean, pure CSS.
The problem with static scalingThe scaling issue is particularly evident when comparing professional typography with browser defaults. Take this example (adapted from Wikipedia), where the first “2” is professionally designed and included in the glyph set, while the second uses <sub> (top) and <sup> (bottom) elements:
Browsers have historically used font-size: smaller for <sup> and <sub> elements, which translates to roughly 0.83x scaling. While this made sense in the early days of CSS for simple documents, it can create problems in modern responsive designs where font sizes can vary dramatically. This is especially true when using fluid typography, where text sizes can scale smoothly between extremes.
Fluid scaling: A better solutionI’ve developed a solution that scales more naturally across different sizes by combining fixed and proportional units. This approach ensures legibility at small sizes while maintaining proper proportions at larger sizes, eliminating the need for context-specific adjustments.
CodePen Embed FallbackHere’s how it works:
sup, sub { font-size: calc(0.5em + 4px); vertical-align: baseline; position: relative; top: calc(-0.5 * 0.83 * 2 * (1em - 4px)); /* Simplified top: calc(-0.83em + 3.32px) */ } sub { top: calc(0.25 * 0.83 * 2 * (1em - 4px)); /* Simplified top: calc(0.42em - 1.66px) */ }- Natural scaling: The degressive formula ensures that superscripts and subscripts remain proportional at all sizes
- Baseline alignment: By using vertical-align: baseline and relative positioning, we prevent the elements from affecting line height and it gives us better control over the offset to match your specific needs. You’re probably also wondering where the heck these values come from — I’ll explain in the following.
Let’s look at how this works, piece by piece:
Calculating the font size (px)At small sizes, the fixed 4px component has more impact. At large sizes, the 0.5em proportion becomes dominant. The result is more natural scaling across all sizes.
sup, sub { font-size: calc(0.5em + 4px); /* ... */ } sub { /* ... */ } Calculating the parent font size (em)Within the <sup> and <sub> elements, we can calculate the parent’s font-size:
sup, sub { font-size: calc(0.5em + 4px); top: calc(2 * (1em - 4px)); } sub { top: calc(2 * (1em + 4px)); }The fluid font size is defined as calc(0.5em + 4px). To compensate for the 0.5em, we first need to solve 0.5em * x = 1em which gives us x = 2. The 1em here represents the font size of the <sup> and <sub> elements themselves. We subtract the 4px fixed component from our current em value before multiplying.
The vertical offsetFor the vertical offset, we start with default CSS positioning values and adjust them to work with our fluid scaling:
sup, sub { font-size: calc(0.5em + 4px); top: calc(-0.5 * 0.83 * 2 * (1em - 4px)); } sub { top: calc(0.25 * 0.83 * 2 * (1em - 4px)); }The formula is carefully calibrated to match standard browser positioning:
- 0.5em (super) and 0.25em (sub) are the default vertical offset values (e.g. used in frameworks like Tailwind CSS and Bootstrap).
- We multiply by 0.83 to account for the browser’s font-size: smaller scaling factor, which is used per default for superscript and subscript.
This approach ensures that our superscripts and subscripts maintain familiar vertical positions while benefiting from improved fluid scaling. The result matches what users expect from traditional browser rendering but scales more naturally across different font sizes.
Helpful tipsThe exact scaling factor font-size: (0.5em + 4px) is based on my analysis of superscript Unicode characters in common fonts. Feel free to adjust these values to match your specific design needs. Here are a few ways how you might want to customize this approach:
For larger scaling:
sup, sub { font-size: calc(0.6em + 3px); /* adjust offset calculations accordingly */ }For smaller scaling:
sup, sub { font-size: calc(0.4em + 5px); /* adjust offset calculations accordingly */ }For backward compatibility, you might want to wrap all of it in a @supports block:
@supports (font-size: calc(1em + 1px)) { sup, sub { ... } } Final demoI built this small interactive demo to show different fluid scaling options, compare them to the browser’s static scaling, and fine-tune the vertical positioning to see what works best for your use case:
CodePen Embed Fallback Open Live DemoGive it a try in your next project and happy to hear your thoughts!
Fluid Superscripts and Subscripts originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
CSSWG Minutes Telecon (2024-12-04): Just Use Grid vs. Display: Masonry
The CSS Working Group (CSSWG) meets weekly (or close to it) to discuss and quickly resolve issues from their GitHub that would otherwise be lost in the back-and-forth of forum conversation. While each meeting brings interesting conversation, this past Wednesday (December 4th) was special. The CSSWG met to try and finally squash a debate that has been going on for five years: whether Masonry should be a part of Grid or a separate system.
I’ll try to summarize the current state of the debate, but if you are looking for the long version, I recommend reading CSS Masonry & CSS Grid by Geoff and Choosing a Masonry Syntax in CSS by Miriam Suzanne.
In 2017, it was frequently asked whether Grid could handle masonry layouts; layouts where the columns (or the rows) could hold unevenly sized items without gaps in between. While this is just one of several possibilities with masonry, you can think about the layout popularized by Pinterest:
In 2020, Firefox released a prototype in which masonry was integrated into the CSS Grid layout module. The main voice against it was Rachel Andrew, arguing that it should be its own, separate thing. Since then, the debate has escalated with two proposals from Apple and Google, arguing for and against a grid-integrated syntax, respectively.
There were some technical worries against a grid-masonry implementation that were since resolved. What you have to know is this: right now, it’s a matter of syntax. To be specific, which syntax is
a. is easier to learn for authors and
b. how might this decision impact possible future developments in one or both models (or CSS in general).
In the middle, the W3C Technical Architecture Group (TAG) was asked for input on the issue which has prompted an effort to unify the two proposals. Both sides have brought strong arguments to the table over a series of posts, and in the following meeting, they were asked to lay those arguments once again in a presentation, with the hope of reaching a consensus.
Remember that you can subscribe and read the full minutes on W3C.org
The Battle of PowerPointsAlison Maher representing Google and an advocate of implementing Masonry as a new display value, opened the meeting with a presentation. The main points were:
- Several properties behave differently between masonry and grid.
- Better defaults when setting display: masonry, something that Rachel Andrew recently argued for.
- There was an argument against display: masonry since fallbacks would be more lengthy to implement, whereas in a grid-integrated the fallback to grid is already there. Alison Maher refutes this since “needing one is a temporary problem, so [we] should focus on the future,” and that “authors should make explicit fallback, to avoid surprises.”
- “Positioning in masonry is simpler than grid, it’s only placed in 1 axis instead of 2.”
- Shorthands are also better: “Grid shorthand is complicated, hard to use. Masonry shorthand is easier because don’t need to remember the order.”
- “Placement works differently in grid vs masonry” and “alignment is also very different”
- There will be “other changes for submasonry/subgrid that will lead to divergences.”
- “Integrating masonry into grid will lead to spec bloat, will be harder to teach, and lead to developer confusion.”
alisonmaher: “Conclusion: masonry should be a separate display type”
Jen Simmons, representing the WebKit team and advocate of the “Just Use Grid” approach followed with another presentation. On this side, the main points were:
- Author learning could be skewed since “a new layout type creates a separate tool with separate syntax that’s similar but not the same as what exists […]. They’re familiar but not quite the same”
- The Chrome proposal would add around 10 new properties. “We don’t believe there’s a compelling argument to add so many new properties to CSS.”
- “Chromium argues that their new syntax is more understandable. We disagree, just use grid-auto-flow“
- “When you layout rows in grid, template syntax is a bit different — you stack the template names to physically diagram the names for the rows. Just Use Grid re-uses this syntax exactly; but new masonry layout uses the column syntax for rows”
- “Other difference is the auto-flow — grid’s indicates the primary fill direction, Chrome believes this doesn’t make sense and changed it to match the orientation of lines”
- “Chrome argues that new display type allows better defaults — but the defaults propose aren’t good […] it doesn’t quite work as easily as claimed [see article] requires deep understanding of autosizing”
- “Easier to switch, e.g. at breakpoints or progressive enhancement”
- “Follows CSS design principles to re-use what already exists”
After two presentations with compelling arguments, Lea Verou (also a member of the TAG) followed with their input.
lea: We did a TAG review on this. My opinion is fully reflected there. I think the arguments WebKit team makes are compelling. We thought not only should masonry be part of grid, but should go further. A lot of arguments for integrating is that “grid is too hard”. In that case we should make grid things easier. Complex things are possible, but simple things are not so easy.
Big part of Google’s argument is defaults, but we could just have smarter defaults — there is precedent for this in CSS if we decided that would help ergonomics We agree that switching between grid vs. masonry is common. Grid might be a slightly better fallback than nothing, but minor argument because people can use @supports. Introducing all these new properties increasing the API surfaces that authors need to learn. Less they can port over. Even if we say we will be disciplined, experience shows that we won’t. Even if not intentional, accidental. DRY – don’t have multiple sources of truth
One of arguments against masonry in grid is that grids are 2D, but actually in graphic design grids were often 1D. I agree that most masonry use cases need simpler grids than general grid use cases, but that means we should make those grids easier to define for both grid and masonry. The more we looked into this, we realize there are 3 different layout modes that give you 2D arrangement of children. We recommended not just make masonry part of grid, but find ways of integrating what we already have better could we come up with a shorthand that sets grid-auto-flow and flex-direction, and promote that for layout direction in general? Then authors only need to learn one control for it.
The debateAll was laid out onto the table, it was only left what other members had to say.
oriol: Problem with Jen Simmons’s reasoning. She said the proposed masonry-direction property would be new syntax that doesn’t match grid-auto-flow property, but this property matches flex-direction property so instead of trying to be close to grid, tries to be close to flexbox. Closer to grid is a choice, could be consistent with different things.
astearns: One question I asked is, has anyone changed their mind on which proposal they support? I personally have. I thought that separate display property made a lot more sense, in terms of designing the feature and I was very daunted by the idea that we’d have to consider both grid and masonry for any new development in either seemed sticky to me but the TAG argument convinced me that we should do the work of integrating these things.
TabAtkins: Thanks for setting that up for me, because I’m going to refute the TAG argument! I think they’re wrong in this case. You can draw a lot of surface-level connections between Grid and Masonry, and Flexbox, and other hypothetical layouts but when you actually look at details of how they work, behaviors each one is capable of, they’re pretty distinct if you try to combine together, it would be an unholy mess of conflicting constraints — e.g. flexing in items of masonry or grid or you’d have a weird mish-mash of, “the 2D layout.
But if you call it a flex you get access to these properties, call it grid, access to these other properties concrete example, “pillar” example mentioned in webKit blog post, that wasn’t compatible with the base concepts in masonry and flex because it wants a shared block formatting context grid etc have different formatting contexts, can’t use floats.
lea: actually, the TAG argument was that layout seems to actually be a continuum, and syntax should accommodate that rather than forcing one of two extremes (current flex vs current grid).
The debate kept back and forth until there was an attempt to set a general north star to follow.
jyasskin: Wanted to emphasize a couple aspects of TAG review. It seems really nice to keep the property from Chrome proposal that you don’t have to learn both, can just learn to do masonry without learning all of Grid even if that’s in a unified system perhaps still define masonry shorthand, and have it set grid properties
jensimmons: To create a simple masonry-style layout in Grid, you just need 3 lines of code (4 with a gap). It’s quite simple.
jyasskin: Most consensus part of TAG feedback was to share properties whenever possible. Not necessary to share the same ‘display’ values; could define different ‘display’ values but share the properties. One thing we didn’t like about unified proposal was grid-auto-flow in the unified proposal, where some values were ignored. Yeah, this is the usability point I’m pounding on
Another Split DecisionDespite all, it looked like nobody was giving away, and the debate seemed stuck once again:
astearns: I’m not hearing a way forward yet. At some point, one of the camps is going to have to concede in order to move this forward.
lea: What if we do a straw poll. Not to decide, but to figure out how far are we from consensus?
The votes were cast and the results were… split.
florian: though we could still not reach consensus, I want to thank both sides for presenting clear arguments, densely packed, well delivered. I will go back to the presentations, and revisit some points, it really was informative to present the way it was.
That’s all folks, a split decision! There isn’t a preference for either of the two proposals and implementing something with such mixed opinions is something nobody would approve. After a little over five years of debate, I think this meeting is yet another good sign that a new proposal addressing the concerns of both sides should be considered, but that’s just a personal opinion. To me, masonry (or whatever name it may be) is an important step in CSS layout that may shape future layouts, it shouldn’t be rushed so until then, I am more than happy to wait for a proposal that satisfies both sides.
Further Reading- Help us choose the final syntax for Masonry in CSS (Jen Simmons and Elika Etemad, with Brandon Stewart)
- Feedback needed: How should we define CSS masonry? (Rachel Andrew, Ian Kilpatrick and Tab Atkins-Bittner)
- Weighing in on CSS Masonry (Keith J. Grant)
- Masonry and good defaults (Rachel Andrew)
- Should masonry be part of CSS grid? (Ahmad Shadeed)
- Pinterest/Masonry style layout support #945
- Designer/developer feedback on masonry layout #10233
- Alternative masonry path forward #9041
- CSS Masonry Layout #1003
- Masonry Syntax Debate #11243
CSSWG Minutes Telecon (2024-12-04): Just Use Grid vs. Display: Masonry originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Smashing Meets Product Design
I had the pleasure of hosting a recent Smashing Magazine workshop on product design, subbing for Vitaly Friedman who usually runs these things.
What? A front-ender interviewing really smart people about their processes for user research, documenting requirements, and scaling teams around usability? I was a product designer once upon a time and even though it’s been a long time since I’ve flexed that muscle, it was a hoot learning from the guests, which included: Chris Kolb, Kevin Hawkins, and Vicky Carmichael.
The videos are barred from embedding, so I’ll simply link ’em up directly to YouTube:
- Chris Kolb: All Users Are Stupid
- Kevin Hawkins: Scaling International User Research
- Vicky Carmichael: Design By Doing
I also moderated a follow-up discussion with Chris and Kevin following the presentations.
A few of my choice takeaways:
- Small teams have the luxury of being in greater, more intimate contact with customers. Vicky explained how their relatively small size (~11 employees) means that everyone interfaces with customers and that customer issues and requests are handled more immediately.
- Large teams have to be mindful of teams forming into individual silos. A silo mentality typically happens when teams scale up in size, resulting in less frequent communication and collaboration. Team dashboards help, as do artifacts from meetings in multiple formats, such as AI-flavored summaries, video recordings, and documented decisions.
- Customers may appear to be dumb, but what looks like dumbness is often what happens when humans are faced with a lack of time and context. Solving “dumb” user problems often means coming at the problem in the same bewildered context rather than simply assuming the customer “just doesn’t get it.”
Smashing Meets Product Design originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Yet Another Anchor Positioning Quirk
I strongly believe Anchor Positioning will go down as one of the greatest additions to CSS. It may not be as game-changing as Flexbox or Grid, but it does fill a positioning gap that has been missing for decades. As awesome as I think it is, CSS Anchor Positioning has a lot of quirks, some of which are the product of its novelty and others due to its unique way of working. Today, I want to bring you yet another Anchor Positioning quirk that has bugged me since I first saw it.
The inceptionIt all started a month ago when I was reading about what other people have made using Anchor Positioning, specifically this post by Temani Afif about “Anchor Positioning & Scroll-Driven Animations.” I strongly encourage you to read it and find out what caught my eye there. Combining Anchor Positioning and Scroll-Driven Animation, he makes a range slider that changes colors while it progresses.
CodePen Embed FallbackAmazing by itself, but it’s interesting that he is using two target elements with the same anchor name, each attached to its corresponding anchor, just like magic. If this doesn’t seem as interesting as it looks, we should then briefly recap how Anchor Positioning works.
CSS Anchor Positioning and the anchor-scope propertySee our complete CSS Anchor Positioning Guide for a comprehensive deep dive.
Anchor Positioning brings two new concepts to CSS, an anchor element and a target element. The anchor is the element used as a reference for positioning other elements, hence the anchor name. While the target is an absolutely-positioned element placed relative to one or more anchors.
An anchor and a target can be almost every element, so you can think of them as just two div sitting next to each other:
<div class="anchor">anchor</div> <div class="target">target</div>To start, we first have to register the anchor element in CSS using the anchor-name property:
.anchor { anchor-name: --my-anchor; }And the position-anchor property on an absolutely-positioned element attaches it to an anchor of the same name. However, to move the target around the anchor we need the position-area property.
.target { position: absolute; position-anchor: --my-anchor; position-area: top right; } CodePen Embed FallbackThis works great, but things get complicated if we change our markup to include more anchors and targets:
<ul> <li> <div class="anchor">anchor 1</div> <div class="target">target 1</div> </li> <li> <div class="anchor">anchor 2</div> <div class="target">target 2</div> </li> <li> <div class="anchor">anchor 3</div> <div class="target">target 3</div> </li> </ul>Instead of each target attaching to its closest anchor, they all pile up at the last registered anchor in the DOM.
CodePen Embed FallbackThe anchor-scope property was introduced in Chrome 131 as an answer to this issue. It limits the scope of anchors to a subtree so that each target attaches correctly. However, I don’t want to focus on this property, because what initially caught my attention was that Temani didn’t use it. For some reason, they all attached correctly, again, like magic.
What’s happening?Targets usually attach to the last anchor on the DOM instead of their closest anchor, but in our first example, we saw two anchors with the same anchor-name and their corresponding targets attached. All this without the anchor-scope property. What’s happening?
Two words: Containing Block.
Something to know about Anchor Positioning is that it relies a lot on how an element’s containing block is built. This isn’t something inherently from Anchor Positioning but from absolute positioning. Absolute elements are positioned relative to their containing block, and inset properties like top: 0px, left: 30px or inset: 1rem are just moving an element around its containing block boundaries, creating what’s called the inset-modified containing block.
A target attached to an anchor isn’t any different, and what the position-area property does under the table is change the target’s inset-modified containing block so it is right next to the anchor.
Usually, the containing block of an absolutely-positioned element is the whole viewport, but it can be changed by any ancestor with a position other than static (usually relative). Temani takes advantage of this fact and creates a new containing block for each slider, so they can only be attached to their corresponding anchors. If you snoop around the code, you can find it at the beginning:
label { position: relative; /* No, It's not useless so don't remove it (or remove it and see what happens) */ }If we use this tactic on our previous examples, suddenly they are all correctly attached!
CodePen Embed Fallback Yet another quirkWe didn’t need to use the anchor-scope property to attach each anchor to its respective target, but instead took advantage of how the containing block of absolute elements is computed. However, there is yet another approach, one that doesn’t need any extra bits of code.
This occurred to me when I was also experimenting with Scroll-Driven Animations and Anchor Positioning and trying to attach text-bubble footnotes on the side of a post, like the following:
Logically, each footnote would be a target, but the choice of an anchor is a little more tricky. I initially thought that each paragraph would work as an anchor, but that would mean having more than one anchor with the same anchor-name. The result: all the targets would pile up at the last anchor:
CodePen Embed FallbackThis could be solved using our prior approach of creating a new containing block for each note. However, there is another route we can take, what I call the reductionist method. The problem comes when there is more than one anchor with the same anchor-name, so we will reduce the number of anchors to one, using an element that could work as the common anchor for all targets.
In this case, we just want to position each target on the sides of the post so we can use the entire body of the post as an anchor, and since each target is naturally aligned on the vertical axis, what’s left is to move them along the horizontal axis:
CodePen Embed FallbackYou can better check how it was done on the original post!
ConclusionThe anchor-scope may be the most recent CSS property to be shipped to a browser (so far, just in Chrome 131+), so we can’t expect its support to be something out of this world. And while I would love to use it every now and there, it will remain bound to short demos for a while. This isn’t a reason to limit the use of other Anchor Positioning properties, which are supported in Chrome 125 onwards (and let’s hope in other browsers in the near future), so I hope these little quirks can help you to keep using Anchor Positioning without any fear.
Yet Another Anchor Positioning Quirk originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
CSS Wrapped 2024
Join the Chrome DevRel team and a skateboarding Chrome Dino on a journey through the latest CSS launched for Chrome and the web platform in 2024, highlighting 17 new features
That breaks down (approximately) as:
Components Interactions Developer experiencePlus:
CSS Wrapped 2024 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Knowing CSS is Mastery to Frontend Development
Anselm Hannemann on the intersection between frameworks and learning the basics:
Nowadays people can write great React and TypeScript code. Most of the time a component library like MUI, Tailwind and others are used for styling. However, nearly no one is able to judge whether the CSS in the codebase is good or far from optimal. It is magically applied by our toolchain into the HTML and we struggle to understand why the website is getting slower and slower.
Related, from Alex Russell:
Many need help orienting themselves as to which end of the telescope is better for examining frontend problems. Frameworkism is now the dominant creed of frontend discourse. It insists that all user problems will be solved if teams just framework hard enough. This is non-sequitur, if not entirely backwards. In practice, the only thing that makes web experiences good is caring about the user experience — specifically, the experience of folks at the margins. Technologies come and go, but what always makes the difference is giving a toss about the user.
Knowing CSS is Mastery to Frontend Development originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Steven Heller’s Font of the Month: Gigafly
Read the book, Typographic Firsts
This month, Steven Heller takes a closer look at the Gigafly font family.
The post Steven Heller’s Font of the Month: Gigafly appeared first on I Love Typography.
The Law of Diminishing Returns
Some animation can make things feel natural. Too many animations becomes distracting.
Some line spacing can help legibility. Too much hurts it.
Some alt text is contextual. Too much alt text is noise.
Some padding feels comfy. Too much padding feels exposed.
Some specificity is manageable. Too much specificity is untenable.
Some technical debt is healthy. Too much of it becomes a burden.
Some corner rounding is classy. Too much is just a circle.
Some breakpoints are fluid. Too many of them becomes adaptive.
Some margin adds breathing room. Too much margin collapses things.
Some images add context. Too many images takes a long time to download (and impacts the environment).
Some JavaScript enhances interactions. Too much becomes a bottleneck.
A font pairing creates a typographic system. Too many pairings creates a visual distraction.
Some utility classes come in handy. Too many eliminates a separation of concerns.
Some data helps make decisions. Too much data kills the vibe.
Some AI can help write the boring parts of code. Too much puts downward pressure on code quality.
Some SEO improves search ranking. Too much mutes the human voice.
Some testing provides good coverage. Too much testing requires its own maintenance.
A few colors establish a visual hierarchy. Too many establish a cognitive dissonance.
Some planning helps productivity. Too much planning creates delays.
Striking the right balance can be tough. We don’t want cool mama bear’s porridge or hot papa’s bear porridge, but something right in the middle, like baby bear’s porridge.
The Law of Diminishing Returns originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
One of Those “Onboarding” UIs, With Anchor Positioning
Welcome to “Anchor Positioning 101” where we will be exploring this interesting new CSS feature. Our textbook for this class will be the extensive “Anchor Positioning Guide” that Juan Diego Rodriguez published here on CSS-Tricks.
I’m excited for this one. Some of you may remember when CSS-Tricks released the “Flexbox Layout Guide” or the “Grid Layout Guide” — I certainly do and still have them both bookmarked! I spend a lot of time flipping between tabs to make sure I have the right syntax in my “experimental” CodePens.
I’ve been experimenting with CSS anchor positioning like the “good old days” since Juan published his guide, so I figured it’d be fun to share some of the excitement, learn a bit, experiment, and of course: build stuff!
CSS Anchor Positioning introductionAnchor positioning lets us attach — or “anchor” — one element to one or more other elements. More than that, it allows us to define how a “target” element (that’s what we call the element we’re attaching to an anchor element) is positioned next to the anchor-positioned element, including fallback positioning in the form of a new @position-try at-rule.
The most hand-wavy way to explain the benefits of anchor positioning is to think of it as a powerful enhancement to position: absolute; as it helps absolutely-positioned elements do what you expect. Don’t worry, we’ll see how this works as we go.
Anchor positioning is currently a W3C draft spec, so you know it’s fresh. It’s marked as “limited availability” in Baseline which at the time of writing means it is limited to Chromium-based browsers (versions 125+). That said, the considerate folks over at Oddbird have a polyfill available that’ll help out other browsers until they ship support.
This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
DesktopChromeFirefoxIEEdgeSafari125NoNo125NoMobile / TabletAndroid ChromeAndroid FirefoxAndroidiOS Safari131No131NoOddbird contributes polyfills for many new CSS features and you (yes, you!) can support their work on Github or Open Collective!
Tab Atkins-Bittner, contributing author to the W3C draft spec on anchor positioning, spoke on the topic at CSS Day 2024. The full conference talk is available on YouTube:
Here at CSS-Tricks, Juan demonstrated how to mix and match anchor positioning with view-driven animations for an awesome floating notes effect:
Front-end friend Kevin Powell recently released a video demonstrating how “CSS Popover + Anchor Positioning is Magical”.
And finally, in the tradition of “making fun games to learn CSS,” Thomas Park released Anchoreum (a “Flexbox Froggy“-type game) to learn about CSS anchor positioning. Highly recommend checking this out to get the hang of the position-area property!
The homeworkOK, now that we’re caught up on what CSS anchor positioning is and the excitement surrounding it, let’s talk about what it does. Tethering an element to another element? That has a lot of potential. Quite a few instances I can remember where I’ve had to fight with absolute positioning and z-index in order to get something positioned just right.
Let’s take a quick look at the basic syntax. First, we need two elements, an anchor-positioned element and the target element that will be tethered to it.
<!-- Anchor element --> <div id="anchor"> Anchor </div> <!-- Target element --> <div id="target"> Target </div>We set an element as an anchor-positioned element by providing it with an anchor-name. This is a unique name of our choosing, however it needs the double-dash prefix, like CSS custom properties.
#anchor { anchor-name: --anchor; }As for our target element, we’ll need to set position: absolute; on it as well as tell the element what anchor to tether to. We do that with a new CSS property, position-anchor using a value that matches the anchor-name of our anchor-positioned element.
#anchor { anchor-name: --anchor; } #target { position: absolute; position-anchor: --anchor; }May not look like it yet, but now our two elements are attached. We can set the actual positioning on the target element by providing a position-area. To position our target element, position-area creates an invisible 3×3 grid over the anchor-positioned element. Using positioning keywords, we can designate where the target element appears near the anchor-positioned element.
#target { position: absolute; position-anchor: --anchor; position-area: top center; }Now we see that our target element is anchored to the top-center of our anchor-positioned element!
CodePen Embed Fallback Anchoring pseudo-elementsWhile playing with anchor positioning, I noticed you can anchor pseudo-elements, just the same as any other element.
#anchor { anchor-name: --anchor; &::before { content: "Target"; position: absolute; position-anchor: --anchor; left: anchor(center); bottom: anchor(center); } } CodePen Embed FallbackMight be useful for adding design flourishes to elements or adding functionality as some sort of indicator.
Moving anchorsAnother quick experiment was to see if we can move anchors. And it turns out this is possible!
CodePen Embed FallbackNotice the use of anchor() functions instead of position-area to position the target element.
#target { position: absolute; position-anchor: --anchor-one; top: anchor(bottom); left: anchor(left); }CSS anchor functions are an alternate way to position target elements based on the computed values of the anchor-positioned element itself. Here we are setting the target element’s top property value to match the anchor-positioned element’s bottom value. Similarly, we can set the target’s left property value to match the anchor-positioned element’s left value.
Hovering over the container element swaps the position-anchor from --anchor-one to --anchor-two.
.container:hover { #target { position-anchor: --anchor-two; } }We are also able to set a transition as we position the target using top and left, which makes it swap smoothly between anchors.
Extra experimentalAlong with being the first to release CSS anchor-positioning, the Chrome dev team recently released new pseudo-selectors related to the <details> and <summary> elements. The ::details-content pseudo-selector allows you to style the “hidden” part of the <details> element.
With this information, I thought: “can I anchor it?” and sure enough, you can!
CodePen Embed FallbackAgain, this is definitely not ready for prime-time, but it’s always fun to experiment!
Practical examinationsLet’s take this a bit further and tackle more practical challenges using CSS anchor positioning. Please keep in mind that all these examples are Chrome-only at the time of writing!
TooltipsOne of the most straightforward use cases for CSS anchor positioning is possibly a tooltip. Makes a lot of sense: hover over an icon and a label floats nearby to explain what the icon does. I didn’t quite want to make yet another tutorial on how to make a tooltip and luckily for me, Zell Liew recently wrote an article on tooltip best practices, so we can focus purely on anchor positioning and refer to Zell’s work for the semantics.
CodePen Embed FallbackNow, let’s check out one of these tooltips:
<!-- ... -->; <li class="toolbar-item">; <button type="button" id="inbox-tool" aria-labelledby="inbox-label" class="tool"> <svg id="inbox-tool-icon"> <!-- SVG icon code ... --> </svg> </button> <div id="inbox-label" role="tooltip"> <p>Inbox</p> </div> </li> <!-- ... -->The HTML is structured in a way where the tooltip element is a sibling of our anchor-positioned <button>, notice how it has the [aria-labelledby] attribute set to match the tooltip’s [id]. The tooltip itself is a generic <div>, semantically enhanced to become a tooltip with the [role="tooltip"] attribute. We can also use [role="tooltip"] as a semantic selector to add common styles to tooltips, including the tooltip’s positioning relative to its anchor.
First, let’s turn our button into an anchored element by giving it an anchor-name. Next, we can set the target element’s position-anchor to match the anchor-name of the anchored element. By default, we can set the tooltip’s visibility to hidden, then using CSS sibling selectors, if the target element receives hover or focus-visible, we can then swap the visibility to visible.
/* Anchor-positioned Element */ #inbox-tool { anchor-name: --inbox-tool; } /* Target element */ [role="tooltip"]#inbox-label { position-anchor: --inbox-tool } /* Target positioning */ [role="tooltip"] { position: absolute; position-area: end center; /* Hidden by default */ visibility: hidden; } /* Visible when tool is hovered or receives focus */ .tool:hover + [role="tooltip"], .tool:focus-visible + [role="tooltip"] { visibility: visible; }Ta-da! Here we have a working, CSS anchor-positioned tooltip!
As users of touch devices aren’t able to hover over elements, you may want to explore toggletips instead!
Floating disclosuresDisclosures are another common component pattern that might be a perfect use case for anchor positioning. Disclosures are typically a component where interacting with a toggle will open and close a corresponding element. Think of the good ol’ <detail>/<summary> HTML element duo, for example.
Currently, if you are looking to create a disclosure-like component which floats over other portions of your user interface, you might be in for some JavaScript, absolute positioning, and z-index related troubles. Soon enough though, we’ll be able to combine CSS anchor positioning with another newer platform feature [popover] to create some incredibly straightforward (and semantically accurate) floating disclosure elements.
The Popover API provides a non-modal way to elevate elements to the top-layer, while also baking in some great functionality, such as light dismissals.
Zell also has more information on popovers, dialogs, and modality!
One of the more common patterns you might consider as a “floating disclosure”-type component is a dropdown menu. Here is the HTML we’ll work with:
<nav> <button id="anchor">Toggle</button> <ul id="target"> <li><a href="#">Link 1</a></li> <li><a href="#">Link 2</a></li> <li><a href="#">Link 3</a></li> </ul> </nav>We can set our target element, in this case the <ul>, to be our popover element by adding the [popover] attribute.
To control the popover, let’s add the attribute [popoveraction="toggle"] to enable the button as a toggle, and point the [popovertarget] attribute to the [id] of our target element.
<nav> <button id="anchor" popoveraction="toggle" popovertarget="target"> Toggle </button> <ul id="target" popover> <li><a href="#">Link 1</a></li> <li><a href="#">Link 2</a></li> <li><a href="#">Link 3</a></li> </ul> </nav>No JavaScript is necessary, and now we have a toggle-able [popover] disclosure element! The problem is that it’s still not tethered to the anchor-positioned element, let’s fix that in our CSS.
First, as this is a popover, let’s add a small bit of styling to remove the intrinsic margin popovers receive by default from browsers.
ul[popover] { margin: 0; }Let’s turn our button into an anchor-positioned element by providing it with an anchor-name:
ul[popover] { margin: 0; } #anchor { anchor-name: --toggle; }As for our target element, we can attach it to the anchor-positioned element by setting its position to absolute and the position-anchor to our anchor-positioned element’s anchor-name:
ul[popover] { margin: 0; } #anchor { anchor-name: --toggle; } #target { position: absolute; position-anchor: --toggle; }We can also adjust the target’s positioning near the anchor-positioned element with the position-area property, similar to what we did with our tooltip.
ul[popover] { margin: 0; } #anchor { anchor-name: --toggle; } #target { position: absolute; position-anchor: --toggle; position-area: bottom; width: anchor-size(width); }You may notice another CSS anchor function in here, anchor-size()! We can set the target’s width to match the width of the anchor-positioned element by using anchor-size(width).
CodePen Embed FallbackThere is one more neat thing we can apply here, fallback positioning! Let’s consider that maybe this dropdown menu might sometimes be located at the bottom of the viewport, either from scrolling or some other reason. We don’t really want it to overflow or cause any extra scrolling, but instead, swap to an alternate location that is visible to the user.
Anchor positioning makes this possible with the postion-try-fallbacks property, a way to provide an alternate location for the target element to display near an anchor-positioned element.
#target { position: absolute; position-anchor: --toggle; position-area: bottom; postion-try-fallbacks: top; width: anchor-size(width); }To keep things simple for our demo, we can add the opposite value of the value of the postion-area property: top.
CodePen Embed Fallback Shopping cart componentWe know how to make a tooltip and a disclosure element, now let’s build upon what we’ve learned so far and create a neat, interactive shopping cart component.
Let’s think about how we want to mark this up. First, we’ll need a button with a shopping cart icon:
<button id="shopping-cart-toggle"> <svg id="shopping-cart-icon" /> <!-- SVG icon code ... --> </svg> </button>We can already reuse what we learned with our tooltip styles to provide a functioning label for this toggle. Let’s add the class .tool to the button, then include a tooltip as our label.
<!-- Toggle --> <button id="shopping-cart-toggle" aria-labelledby="shopping-cart-label" class="tool"> <svg id="shopping-cart-icon" /> <!-- SVG icon code ... --> </svg> </button> <!-- Tooltip --> <div id="shopping-cart-label" role="tooltip" class="tooltip"> <p>Shopping Cart</p> </div>We’ll need to specify our <button> is an anchor-positioned element in CSS with an anchor-name, which we can also set as the tooltip’s position-anchor value to match.
button#shopping-cart-toggle { anchor-name: --shopping-cart-toggle; } [role="tooltip"]#shopping-cart-label { position-anchor: --shopping-cart-toggle; }Now we should have a nice-looking tooltip labeling our shopping cart button!
But wait, we want this thing to do more than that! Let’s turn it into a disclosure component that reveals a list of items inside the user’s cart. As we are looking to have a floating user-interface with a few actions included, we should consider a <dialog> element. However, we don’t necessarily want to be blocking background content, so we can opt for a non-modal dialog using the[popover] attribute again!
<!-- Toggle --> <button id="shopping-cart-toggle" aria-labelledby="shopping-cart-label" class="tool" popovertarget="shopping-cart" popoveraction="toggle"> <svg id="shopping-cart-icon" /> <!-- SVG icon code ... --> </svg> </button> <!-- Tooltip --> <div id="shopping-cart-label" role="tooltip" class="tooltip"> <p>Shopping Cart</p> </div> <!-- Shopping Cart --> <dialog id="shopping-cart" popover> <!-- Shopping cart template... --> <button popovertarget="shopping-cart" popoveraction="close"> Dismiss Cart </button> </dialog>To control the popover, we’ve added [popovertarget="shopping-cart"] and [popoveraction="toggle"] to our anchor-positioned element and included a second button within the <dialog> that can also be used to close the dialog with [popoveraction="close"].
To anchor the shopping cart <dialog> to the toggle, we can set position-anchor and position-area:
#shopping-cart { position-anchor: --shopping-cart; position-area: end center; }At this point, we should take a moment to realize that we have tethered two elements to the same anchor!
We won’t stop there, though. There is one more enhancement we can make to really show how helpful anchor positioning can be: Let’s add a notification badge to the element to describe how many items are inside the cart.
Let’s place the badge inside of our anchor-positioned element this time.
<!-- Toggle --> <button id="shopping-cart-toggle" aria-labelledby="shopping-cart-label" class="tool" popovertarget="shopping-cart" popoveraction="toggle"> <svg id="shopping-cart-icon" /> <!-- SVG icon code ... --> </svg> <!-- Notification Badge --> <div id="shopping-cart-badge" class="notification-badge"> 1 </div> </button> <!-- ... -->We can improve our tooltip to include verbiage about how many items are in the cart:
<!-- Tooltip --> <div id="shopping-cart-label" role="tooltip"> <p>Shopping Cart</p> <p>(1 item in cart)</p> </div>Now the accessible name of our anchor-positioned element will be read as Shopping Cart (1 item in cart), which helps provide context to assistive technologies like screen readers.
Let’s tether this notification badge to the same anchor as our tooltip and shopping cart <dialog>, we can do this by setting the position-anchor property of the badge to --shopping-cart-toggle:
#shopping-cart-badge { position: absolute; position-anchor: --shopping-cart-toggle; }Let’s look at positioning. We don’t want it below or next to the anchor, we want it overlapping, so we can use CSS anchor functions to position it based on the anchor-positioned element’s dimensions.
#shopping-cart-badge { position: absolute; position-anchor: --shopping-cart-toggle; bottom: anchor(center); left: anchor(center); }Here we are setting the bottom and left of the target element to match the anchor’s center. This places it in the upper-right corner of the SVG icon!
Folks, this means we have three elements anchored now. Isn’t that fantastic?
CodePen Embed Fallback Combining thingsTo put these anchor-positioned elements into perspective, I’ve combined all the techniques we’ve learned so far into a more familiar setting:
CodePen Embed FallbackDisclosure components, dropdown menus, tooltips (and toggletips!), as well as notification badges all made much simpler using CSS anchor positioning!
Final projectAs a final project for myself (and to bring this whole thing around full-circle), I decided to try to build a CSS anchor-positioned-based onboarding tool. I’ve previously attempted to build a tool like this at work, which I called “HandHoldJS” and it… well, it didn’t go so great. I managed to have a lot of the core functionality working using JavaScript, but it meant keeping track of quite a lot of positions and lots of weird things kept happening!
Let’s see if we can do better with CSS anchor positioning.
CodePen Embed FallbackFeel free to check out the code on CodePen! I went down quite a rabbit hole on this one, so I’ll provide a bit of a high-level overview here.
<hand-hold> is a native custom element that works entirely in the light DOM. It sort of falls into the category of an HTML web component, as it is mostly based on enabling its inner HTML. You can specify tour stops to any element on the page by adding [data-tour-stop] attributes with values in the order you want the tour to occur.
Inside the <hand-hold> element contains a <button> to start the tour, a <dialog> element to contain the tour information, <section> elements to separate content between tour stops, a fieldset[data-handhold-navigation] element which holds navigation radio buttons, as well as another <button> to end the tour.
Each <section> element corresponds to a tour stop with a matching [data-handhold-content] attribute applied. Using JavaScript, <hand-hold> dynamically updates tour stops to be anchor-positioned elements, which the <dialog> can attach itself (there is a sneaky pseudo-element attached to the anchor to highlight the tour stop element!).
Although the <dialog> element is attached via CSS anchor positioning, it also moves within the DOM to appear next to the anchor-position element in the accessibility tree. The (well-meaning) intention here is to help provide more context to those who may be navigating via assistive devices by figuring out which element the dialog is referring to. Believe me, though, this thing is far from perfect as an accessible user experience.
Also, since the <dialog> moves throughout the DOM, unfortunately, a simple CSS transition would not suffice. Another modern browser feature to the rescue yet again, as we can pass a DOM manipulation function into a View Transition, making the transitions feel smoother!
There is still quite a lot to test with this, so I would not recommend using <hand-hold> in a production setting. If for no other reason than browser support is quite limited at the moment!
This is just an experiment to see what I could cook up using CSS anchor positioning, I’m excited for the potential!
Class dismissed!After seeing what CSS anchor positioning is capable of, I have suspicions that it may change a lot of the ways we write CSS, similar to the introduction of flexbox or grid.
I’m excited to see what other user interface patterns can be accomplished with anchor positioning, and I’m even more excited to see what the community will do with it once it’s more broadly available!
One of Those “Onboarding” UIs, With Anchor Positioning originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
WordPress Multi-Multisite: A Case Study
The mission: Provide a dashboard within the WordPress admin area for browsing Google Analytics data for all your blogs.
The catch? You’ve got about 900 live blogs, spread across about 25 WordPress multisite instances. Some instances have just one blog, others have as many as 250. In other words, what you need is to compress a data set that normally takes a very long time to compile into a single user-friendly screen.
The implementation details are entirely up to you, but the final result should look like this Figma comp:
Design courtesy of the incomparable Brian Biddle.I want to walk you through my approach and some of the interesting challenges I faced coming up with it, as well as the occasional nitty-gritty detail in between. I’ll cover topics like the WordPress REST API, choosing between a JavaScript or PHP approach, rate/time limits in production web environments, security, custom database design — and even a touch of AI. But first, a little orientation.
Let’s define some termsWe’re about to cover a lot of ground, so it’s worth spending a couple of moments reviewing some key terms we’ll be using throughout this post.
What is WordPress multisite?WordPress Multisite is a feature of WordPress core — no plugins required — whereby you can run multiple blogs (or websites, or stores, or what have you) from a single WordPress installation. All the blogs share the same WordPress core files, wp-content folder, and MySQL database. However, each blog gets its own folder within wp-content/uploads for its uploaded media, and its own set of database tables for its posts, categories, options, etc. Users can be members of some or all blogs within the multisite installation.
What is WordPress multi-multisite?It’s just a nickname for managing multiple instances of WordPress multisite. It can get messy to have different customers share one multisite instance, so I prefer to break it up so that each customer has their own multisite, but they can have many blogs within their multisite.
So that’s different from a “Network of Networks”?It’s apparently possible to run multiple instances of WordPress multisite against the same WordPress core installation. I’ve never looked into this, but I recall hearing about it over the years. I’ve heard the term “Network of Networks” and I like it, but that is not the scenario I’m covering in this article.
Why do you keep saying “blogs”? Do people still blog?You betcha! And people read them, too. You’re reading one right now. Hence, the need for a robust analytics solution. But this article could just as easily be about any sort of WordPress site. I happen to be dealing with blogs, and the word “blog” is a concise way to express “a subsite within a WordPress multisite instance”.
One more thing: In this article, I’ll use the term dashboard site to refer to the site from which I observe the compiled analytics data. I’ll use the term client sites to refer to the 25 multisites I pull data from.
My implementationMy strategy was to write one WordPress plugin that is installed on all 25 client sites, as well as on the dashboard site. The plugin serves two purposes:
- Expose data at API endpoints of the client sites
- Scrape the data from the client sites from the dashboard site, cache it in the database, and display it in a dashboard.
The WordPress REST API is my favorite part of WordPress. Out of the box, WordPress exposes default WordPress stuff like posts, authors, comments, media files, etc., via the WordPress REST API. You can see an example of this by navigating to /wp-json from any WordPress site, including CSS-Tricks. Here’s the REST API root for the WordPress Developer Resources site:
The root URL for the WordPress REST API exposes structured JSON data, such as this example from the WordPress Developer Resources website.What’s so great about this? WordPress ships with everything developers need to extend the WordPress REST API and publish custom endpoints. Exposing data via an API endpoint is a fantastic way to share it with other websites that need to consume it, and that’s exactly what I did:
Open the code <?php [...] function register(\WP_REST_Server $server) { $endpoints = $this->get(); foreach ($endpoints as $endpoint_slug => $endpoint) { register_rest_route( $endpoint['namespace'], $endpoint['route'], $endpoint['args'] ); } } function get() { $version = 'v1'; return array( 'empty_db' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/empty_db', 'args' => array( 'methods' => array( 'DELETE' ), 'callback' => array($this, 'empty_db_cb'), 'permission_callback' => array( $this, 'is_admin' ), ), ), 'get_blogs' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/get_blogs', 'args' => array( 'methods' => array('GET', 'OPTIONS'), 'callback' => array($this, 'get_blogs_cb'), 'permission_callback' => array($this, 'is_dba'), ), ), 'insert_blogs' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/insert_blogs', 'args' => array( 'methods' => array( 'POST' ), 'callback' => array($this, 'insert_blogs_cb'), 'permission_callback' => array( $this, 'is_admin' ), ), ), 'get_blogs_from_db' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/get_blogs_from_db', 'args' => array( 'methods' => array( 'GET' ), 'callback' => array($this, 'get_blogs_from_db_cb'), 'permission_callback' => array($this, 'is_admin'), ), ), 'get_blog_details' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/get_blog_details', 'args' => array( 'methods' => array( 'GET' ), 'callback' => array($this, 'get_blog_details_cb'), 'permission_callback' => array($this, 'is_dba'), ), ), 'update_blogs' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/update_blogs', 'args' => array( 'methods' => array( 'PATCH' ), 'callback' => array($this, 'update_blogs_cb'), 'permission_callback' => array($this, 'is_admin'), ), ), ); }We don’t need to get into every endpoint’s details, but I want to highlight one thing. First, I provided a function that returns all my endpoints in an array. Next, I wrote a function to loop through the array and register each array member as a WordPress REST API endpoint. Rather than doing both steps in one function, this decoupling allows me to easily retrieve the array of endpoints in other parts of my plugin to do other interesting things with them, such as exposing them to JavaScript. More on that shortly.
Once registered, the custom API endpoints are observable in an ordinary web browser like in the example above, or via purpose-built tools for API work, such as Postman:
PHP vs. JavaScriptI tend to prefer writing applications in PHP whenever possible, as opposed to JavaScript, and executing logic on the server, as nature intended, rather than in the browser. So, what would that look like on this project?
- On the dashboard site, upon some event, such as the user clicking a “refresh data” button or perhaps a cron job, the server would make an HTTP request to each of the 25 multisite installs.
- Each multisite install would query all of its blogs and consolidate its analytics data into one response per multisite.
Unfortunately, this strategy falls apart for a couple of reasons:
- PHP operates synchronously, meaning you wait for one line of code to execute before moving to the next. This means that we’d be waiting for all 25 multisites to respond in series. That’s sub-optimal.
- My production environment has a max execution limit of 60 seconds, and some of my multisites contain hundreds of blogs. Querying their analytics data takes a second or two per blog.
Damn. I had no choice but to swallow hard and commit to writing the application logic in JavaScript. Not my favorite, but an eerily elegant solution for this case:
- Due to the asynchronous nature of JavaScript, it pings all 25 Multisites at once.
- The endpoint on each Multisite returns a list of all the blogs on that Multisite.
- The JavaScript compiles that list of blogs and (sort of) pings all 900 at once.
- All 900 blogs take about one-to-two seconds to respond concurrently.
Holy cow, it just went from this:
( 1 second per Multisite * 25 installs ) + ( 1 second per blog * 900 blogs ) = roughly 925 seconds to scrape all the data.To this:
1 second for all the Multisites at once + 1 second for all 900 blogs at once = roughly 2 seconds to scrape all the data.That is, in theory. In practice, two factors enforce a delay:
- Browsers have a limit as to how many concurrent HTTP requests they will allow, both per domain and regardless of domain. I’m having trouble finding documentation on what those limits are. Based on observing the network panel in Chrome while working on this, I’d say it’s about 50-100.
- Web hosts have a limit on how many requests they can handle within a given period, both per IP address and overall. I was frequently getting a “429; Too Many Requests” response from my production environment, so I introduced a delay of 150 milliseconds between requests. They still operate concurrently, it’s just that they’re forced to wait 150ms per blog. Maybe “stagger” is a better word than “wait” in this context:
With these limitations factored in, I found that it takes about 170 seconds to scrape all 900 blogs. This is acceptable because I cache the results, meaning the user only has to wait once at the start of each work session.
The result of all this madness — this incredible barrage of Ajax calls, is just plain fun to watch:
PHP and JavaScript: Connecting the dotsI registered my endpoints in PHP and called them in JavaScript. Merging these two worlds is often an annoying and bug-prone part of any project. To make it as easy as possible, I use wp_localize_script():
<?php [...] class Enqueue { function __construct() { add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_script' ), 10 ); add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_localize' ), 11 ); } function lexblog_network_analytics_script() { wp_register_script( 'lexblog_network_analytics_script', LXB_DBA_URL . '/js/lexblog_network_analytics.js', array( 'jquery', 'jquery-ui-autocomplete' ), false, false ); } function lexblog_network_analytics_localize() { $a = new LexblogNetworkAnalytics; $data = $a -> get_localization_data(); $slug = $a -> get_slug(); wp_localize_script( 'lexblog_network_analytics_script', $slug, $data ); } // etc. }In that script, I’m telling WordPress two things:
- Load my JavaScript file.
- When you do, take my endpoint URLs, bundle them up as JSON, and inject them into the HTML document as a global variable for my JavaScript to read. This is leveraging the point I noted earlier where I took care to provide a convenient function for defining the endpoint URLs, which other functions can then invoke without fear of causing any side effects.
Here’s how that ended up looking:
The JSON and its associated JavaScript file, where I pass information from PHP to JavaScript using wp_localize_script(). Auth: Fort Knox or Sandbox?We need to talk about authentication. To what degree do these endpoints need to be protected by server-side logic? Although exposing analytics data is not nearly as sensitive as, say, user passwords, I’d prefer to keep things reasonably locked up. Also, since some of these endpoints perform a lot of database queries and Google Analytics API calls, it’d be weird to sit here and be vulnerable to weirdos who might want to overload my database or Google Analytics rate limits.
That’s why I registered an application password on each of the 25 client sites. Using an app password in php is quite simple. You can authenticate the HTTP requests just like any basic authentication scheme.
I’m using JavaScript, so I had to localize them first, as described in the previous section. With that in place, I was able to append these credentials when making an Ajax call:
async function fetchBlogsOfInstall(url, id) { let install = lexblog_network_analytics.installs[id]; let pw = install.pw; let user = install.user; // Create a Basic Auth token let token = btoa(`${user}:${pw}`); let auth = { 'Authorization': `Basic ${token}` }; try { let data = await $.ajax({ url: url, method: 'GET', dataType: 'json', headers: auth }); return data; } catch (error) { console.error('Request failed:', error); return []; } }That file uses this cool function called btoa() for turning the raw username and password combo into basic authentication.
The part where we say, “Oh Right, CORS.”Whenever I have a project where Ajax calls are flying around all over the place, working reasonably well in my local environment, I always have a brief moment of panic when I try it on a real website, only to get errors like this:
Oh. Right. CORS. Most reasonably secure websites do not allow other websites to make arbitrary Ajax requests. In this project, I absolutely do need the Dashboard Site to make many Ajax calls to the 25 client sites, so I have to tell the client sites to allow CORS:
<?php // ... function __construct() { add_action( 'rest_api_init', array( $this, 'maybe_add_cors_headers' ), 10 ); } function maybe_add_cors_headers() { // Only allow CORS for the endpoints that pertain to this plugin. if( $this->is_dba() ) { add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 2 ); } } function is_dba() { $url = $this->get_current_url(); $ep_urls = $this->get_endpoint_urls(); $out = in_array( $url, $ep_urls ); return $out; } function send_cors_headers( $served, $result ) { // Only allow CORS from the dashboard site. $dashboard_site_url = $this->get_dashboard_site_url(); header( "Access-Control-Allow-Origin: $dashboard_site_url" ); header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization' ); header( 'Access-Control-Allow-Methods: GET, OPTIONS' ); return $served; } [...] }You’ll note that I’m following the principle of least privilege by taking steps to only allow CORS where it’s necessary.
Auth, Part 2: I’ve been known to auth myselfI authenticated an Ajax call from the dashboard site to the client sites. I registered some logic on all the client sites to allow the request to pass CORS. But then, back on the dashboard site, I had to get that response from the browser to the server.
The answer, again, was to make an Ajax call to the WordPress REST API endpoint for storing the data. But since this was an actual database write, not merely a read, it was more important than ever to authenticate. I did this by requiring that the current user be logged into WordPress and possess sufficient privileges. But how would the browser know about this?
In PHP, when registering our endpoints, we provide a permissions callback to make sure the current user is an admin:
<?php // ... function get() { $version = 'v1'; return array( 'update_blogs' => array( 'namespace' => 'LXB_DBA/' . $version, 'route' => '/update_blogs', 'args' => array( 'methods' => array( 'PATCH' ), 'callback' => array( $this, 'update_blogs_cb' ), 'permission_callback' => array( $this, 'is_admin' ), ), ), // ... ); } function is_admin() { $out = current_user_can( 'update_core' ); return $out; }JavaScript can use this — it’s able to identify the current user — because, once again, that data is localized. The current user is represented by their nonce:
async function insertBlog( data ) { let url = lexblog_network_analytics.endpoint_urls.insert_blog; try { await $.ajax({ url: url, method: 'POST', dataType: 'json', data: data, headers: { 'X-WP-Nonce': getNonce() } }); } catch (error) { console.error('Failed to store blogs:', error); } } function getNonce() { if( typeof wpApiSettings.nonce == 'undefined' ) { return false; } return wpApiSettings.nonce; }The wpApiSettings.nonce global variable is automatically present in all WordPress admin screens. I didn’t have to localize that. WordPress core did it for me.
Cache is KingCompressing the Google Analytics data from 900 domains into a three-minute loading .gif is decent, but it would be totally unacceptable to have to wait for that long multiple times per work session. Therefore I cache the results of all 25 client sites in the database of the dashboard site.
I’ve written before about using the WordPress Transients API for caching data, and I could have used it on this project. However, something about the tremendous volume of data and the complexity implied within the Figma design made me consider a different approach. I like the saying, “The wider the base, the higher the peak,” and it applies here. Given that the user needs to query and sort the data by date, author, and metadata, I think stashing everything into a single database cell — which is what a transient is — would feel a little claustrophobic. Instead, I dialed up E.F. Codd and used a relational database model via custom tables:
In the Dashboard Site, I created seven custom database tables, including one relational table, to cache the data from the 25 client sites, as shown in the image.It’s been years since I’ve paged through Larry Ullman’s career-defining (as in, my career) books on database design, but I came into this project with a general idea of what a good architecture would look like. As for the specific details — things like column types — I foresaw a lot of Stack Overflow time in my future. Fortunately, LLMs love MySQL and I was able to scaffold out my requirements using DocBlocks and let Sam Altman fill in the blanks:
Open the code <?php /** * Provides the SQL code for creating the Blogs table. It has columns for: * - ID: The ID for the blog. This should just autoincrement and is the primary key. * - name: The name of the blog. Required. * - slug: A machine-friendly version of the blog name. Required. * - url: The url of the blog. Required. * - mapped_domain: The vanity domain name of the blog. Optional. * - install: The name of the Multisite install where this blog was scraped from. Required. * - registered: The date on which this blog began publishing posts. Optional. * - firm_id: The ID of the firm that publishes this blog. This will be used as a foreign key to relate to the Firms table. Optional. * - practice_area_id: The ID of the firm that publishes this blog. This will be used as a foreign key to relate to the PracticeAreas table. Optional. * - amlaw: Either a 0 or a 1, to indicate if the blog comes from an AmLaw firm. Required. * - subscriber_count: The number of email subscribers for this blog. Optional. * - day_view_count: The number of views for this blog today. Optional. * - week_view_count: The number of views for this blog this week. Optional. * - month_view_count: The number of views for this blog this month. Optional. * - year_view_count: The number of views for this blog this year. Optional. * * @return string The SQL for generating the blogs table. */ function get_blogs_table_sql() { $slug = 'blogs'; $out = "CREATE TABLE {$this->get_prefix()}_$slug ( id BIGINT NOT NULL AUTO_INCREMENT, slug VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, url VARCHAR(255) NOT NULL UNIQUE, /* adding unique constraint */ mapped_domain VARCHAR(255) UNIQUE, install VARCHAR(255) NOT NULL, registered DATE DEFAULT NULL, firm_id BIGINT, practice_area_id BIGINT, amlaw TINYINT NOT NULL, subscriber_count BIGINT, day_view_count BIGINT, week_view_count BIGINT, month_view_count BIGINT, year_view_count BIGINT, PRIMARY KEY (id), FOREIGN KEY (firm_id) REFERENCES {$this->get_prefix()}_firms(id), FOREIGN KEY (practice_area_id) REFERENCES {$this->get_prefix()}_practice_areas(id) ) DEFAULT CHARSET=utf8mb4;"; return $out; }In that file, I quickly wrote a DocBlock for each function, and let the OpenAI playground spit out the SQL. I tested the result and suggested some rigorous type-checking for values that should always be formatted as numbers or dates, but that was the only adjustment I had to make. I think that’s the correct use of AI at this moment: You come in with a strong idea of what the result should be, AI fills in the details, and you debate with it until the details reflect what you mostly already knew.
How it’s goingI’ve implemented most of the user stories now. Certainly enough to release an MVP and begin gathering whatever insights this data might have for us:
It’s working!One interesting data point thus far: Although all the blogs are on the topic of legal matters (they are lawyer blogs, after all), blogs that cover topics with a more general appeal seem to drive more traffic. Blogs about the law as it pertains to food, cruise ships, germs, and cannabis, for example. Furthermore, the largest law firms on our network don’t seem to have much of a foothold there. Smaller firms are doing a better job of connecting with a wider audience. I’m positive that other insights will emerge as we work more deeply with this.
Regrets? I’ve had a few.This project probably would have been a nice opportunity to apply a modern JavaScript framework, or just no framework at all. I like React and I can imagine how cool it would be to have this application be driven by the various changes in state rather than… drumroll… a couple thousand lines of jQuery!
I like jQuery’s ajax() method, and I like the jQueryUI autocomplete component. Also, there’s less of a performance concern here than on a public-facing front-end. Since this screen is in the WordPress admin area, I’m not concerned about Google admonishing me for using an extra library. And I’m just faster with jQuery. Use whatever you want.
I also think it would be interesting to put AWS to work here and see what could be done through Lambda functions. Maybe I could get Lambda to make all 25 plus 900 requests concurrently with no worries about browser limitations. Heck, maybe I could get it to cycle through IP addresses and sidestep the 429 rate limit as well.
And what about cron? Cron could do a lot of work for us here. It could compile the data on each of the 25 client sites ahead of time, meaning that the initial three-minute refresh time goes away. Writing an application in cron, initially, I think is fine. Coming back six months later to debug something is another matter. Not my favorite. I might revisit this later on, but for now, the cron-free implementation meets the MVP goal.
I have not provided a line-by-line tutorial here, or even a working repo for you to download, and that level of detail was never my intention. I wanted to share high-level strategy decisions that might be of interest to fellow Multi-Multisite people. Have you faced a similar challenge? I’d love to hear about it in the comments!
WordPress Multi-Multisite: A Case Study originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Follow Up: We Officially Have a CSS Logo!
As a follow up to the search for a new CSS logo, it looks like we have a winner!
Since our last post, the color shifted away from a vibrant pink to a color with a remarkable history among the CSS community: rebeccapurple
CodePen Embed FallbackWith 400 votes on GitHub, I think the community has chosen well.
Check out Adam’s post on selecting the winner!
Follow Up: We Officially Have a CSS Logo! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Alt Text: Not Always Needed
Alt text is one of those things in my muscle memory that pops up anytime I’m working with an image element. The attribute almost writes itself.
<img src="image.jpg" alt="">Or if you use Emmet, that’s autocompleted for you. Don’t forget the alt text! Use it even if there’s no need for it, as an empty string is simply skipped by screen readers. That’s called “nulling” the alternative text and many screen readers simply announce the image file name. Just be sure it’s truly an empty string because even a space gets picked up by some assistive tech, which causes a screen reader to completely skip the image:
<!-- Not empty --> <img src="image.jpg" alt=" ">But wait… are there situations where an image doesn’t need alt text? I tend to agree with Eric that the vast majority of images are more than decorative and need to be described. Your images are probably not decorative and ought to be described with alt text.
Probably is doing a lot of lifting there because not all images are equal when it comes to content and context. Emma Cionca and Tanner Kohler have a fresh study on those situations where you probably don’t need alt. It’s a well-written and researched piece and I’m rounding up some nuggets from it.
What Users Need from Alt TextIt’s the same as what anyone else would need from an image: an easy path to accomplish basic tasks. A product image is a good example of that. Providing a visual smooths the path to purchasing because it’s context about what the item looks like and what to expect when you get it. Not providing an image almost adds friction to the experience if you have to stop and ask customer support basic questions about the size and color of that shirt you want.
So, yes. Describe that image in alt! But maybe “describe” isn’t the best wording because the article moves on to make the next point…
Quit Describing What Images Look LikeThe article gets into a common trap that I’m all too guilty of, which is describing an image in a way that I find helpful. Or, as the article says, it’s a lot like I’m telling myself, “I’ll describe it in the alt text so screen-reader users can imagine what they aren’t seeing.”
That’s the wrong way of going about it. Getting back to the example of a product image, the article outlines how a screen reader might approach it:
For example, here’s how a screen-reader user might approach a product page:
- Jump between the page headers to get a sense of the page structure.
- Explore the details of a specific section with the heading label Product Description.
- Encounter an image and wonder “What information that I might have missed elsewhere does this image communicate about the product?”
Interesting! Where I might encounter an image and evaluate it based on the text around it, a screen reader is already questioning what content has been missed around it. This passage is one I need to reflect on (emphasis mine):
Most of the time, screen-reader users don’t wonder what images look like. Instead, they want to know their purpose. (Exceptions to this rule might include websites presenting images, such as artwork, purely for visual enjoyment, or users who could previously see and have lost their sight.)
OK, so how in the heck do we know when an image needs describing? It feels so awkward making what’s ultimately a subjective decision. Even so, the article presents three questions to pose to ourselves to determine the best route.
- Is the image repetitive? Is the task-related information in the image also found elsewhere on the page?
- Is the image referential? Does the page copy directly reference the image?
- Is the image efficient? Could alt text help users more efficiently complete a task?
This is the meat of the article, so I’m gonna break those out.
Is the image repetitive?Repetitive in the sense that the content around it is already doing a bang-up job painting a picture. If the image is already aptly “described” by content, then perhaps it’s possible to get away with nulling the alt attribute.
This is the figure the article uses to make the point (and, yes, I’m alt-ing it):
The caption for this image describes exactly what the image communicates. Therefore, any alt text for the image will be redundant and a waste of time for screen-reader users. In this case, the actual alt text was the same as the caption. Coming across the same information twice in a row feels even more confusing and unnecessary.
The happy path:
<img src="image.jpg" alt="">But check this out this image about informal/semi-formal table setting showing how it is not described by the text around it (and, no, I’m not alt-ing it):
If I was to describe this image, I might get carried away describing the diagram and all the points outlined in the legend. If I can read all of that, then a screen reader should, too, right? Not exactly. I really appreciate the slew of examples provided in the article. A sampling:
- Bread plate and butter knife, located in the top left corner.
- Dessert fork, placed horizontally at the top center.
- Dessert spoon, placed horizontally at the top center, below the dessert fork.
That’s way less verbose than I would have gone. Talking about how long (or short) alt ought to be is another topic altogether.
Is the image referential?The second image I dropped in that last section is a good example of a referential image because I directly referenced it in the content preceding it. I nulled the alt attribute because of that. But what I messed up is not making the image recognizable to screen readers. If the alt attribute is null, then the screen reader skips it. But the screen reader should still know it’s there even if it’s aptly described.
The happy path:
<img src="image.jpg" alt="">Remember that a screen reader may announce the image’s file name. So maybe use that as an opportunity to both call out the image and briefly describe it. Again, we want the screen reader to announce the image if we make mention of it in the content around it. Simply skipping it may cause more confusion than clarity.
Is the image efficient?My mind always goes to performance when I see the word efficient pop up in reference to images. But in this context the article means whether or not the image can help visitors efficiently complete a task.
If the image helps complete a task, say purchasing a product, then yes, the image needs alt text. But if the content surrounding it already does the job then we can leave it null (alt="") or skip it (alt=" ") if there’s no mention of it.
Wrapping upI put a little demo together with some testing results from a few different screen readers to see how all of that shakes out.
CodePen Embed FallbackAlt Text: Not Always Needed originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Solved by CSS: Donuts Scopes
Imagine you have a web component that can show lots of different content. It will likely have a slot somewhere where other components can be injected. The parent component also has its own styles unrelated to the styles of the content components it may hold.
This makes a challenging situation: how can we prevent the parent component styles from leaking inwards?
This isn’t a new problem — Nicole Sullivan described it way back in 2011! The main problem is writing CSS so that it doesn’t affect the content, and she accurately coined it as donut scoping.
“We need a way of saying, not only where scope starts, but where it ends. Thus, the scope donut”.
Even if donut scoping is an ancient issue in web years, if you do a quick search on “CSS Donut Scope” in your search engine of choice, you may notice two things:
- Most of them talk about the still recent @scope at-rule.
- Almost every result is from 2021 onwards.
We get similar results even with a clever “CSS Donut Scope –@scope” query, and going year by year doesn’t seem to bring anything new to the donut scope table. It seems like donut scopes stayed at the back of our minds as just another headache of the ol’ CSS global scope until @scope.
And (spoiler!), while the @scope at-rule brings an easier path for donut scoping, I feel there must have been more attempted solutions over the years. We will venture through each of them, making a final stop at today’s solution, @scope. It’s a nice exercise in CSS history!
Take, for example, the following game screen. We have a .parent element with a tab set and a .content slot, in which an .inventory component is injected. If we change the .parent color, then so does the color inside .content.
CodePen Embed FallbackHow can we stop this from happening? I want to prevent the text inside of .content from inheriting the .parent‘s color.
Just ignore it!The first solution is no solution at all! This may be the most-used approach since most developers can live their lives without the joys of donut scoping (crazy, right?). Let’s be more tangible here, it isn’t just blatantly ignoring it, but rather accepting CSS’s global scope and writing styles with that in mind. Back to our first example, we assume we can’t stop the parent’s styles from leaking inwards to the content component, so we write our parent’s styles with less specificity, so they can be overridden by the content styles.
body { color: blue; } .parent { color: orange; /* Initial background */ } .content { color: blue; /* Overrides parent's background */ } CodePen Embed FallbackWhile this approach is sufficient for now, managing styles just by their specificity as a project grows larger becomes tedious, at best, and chaotic at worst. Components may behave differently depending on where they are slotted and changing our CSS or HTML can break other styles in unexpected ways.
Two CSS properties walk into a bar. A barstool in a completely different bar falls over.
Thomas FuchsYou can see how in this small example we have to override the styles twice:
Shallow donuts scopes with :not()Our goal then it’s to only scope the .parent, leaving out whatever may be inserted into the .content slot. So, not the .content but the rest of .parent… not the .content… :not()! We can use the :not() selector to scope only the direct descendants of .parent that aren’t .content.
body { color: blue; } .parent > :not(.content) { color: orange; }This way the .content styles won’t be bothered by the styles defined in their .parent:
CodePen Embed FallbackYou can see an immense difference when we open the DevTools for each example:
As good as an improvement, the last example has a shallow reach. So, if there were another slot nested deeper in, we wouldn’t be able to reach it unless we know beforehand where it is going to be slotted.
CodePen Embed FallbackThis is because we are using the direct descendant selector (>), but I couldn’t find a way to make it work without it. Even using a combination of complex selectors inside :not() doesn’t seem to lead anywhere useful. For example, back in 2021, Dr. Lea Verou mentioned donut scoping with :not() using the following selector cocktail:
.container:not(.content *) { /* Donut Scoped styles (?) */ }However, this snippet appears to match the .container/.parent class instead of its descendants, and it’s noted that it still would be shallow donut scoping:
TIL that all modern browsers now support complex selectors in :not()! 😍
Test: https://t.co/rHSJARDvSW
So you can do things like:
– .foo :not(.foo .foo *) to match things inside one .foo wrapper but not two
– .container :not(.content *) to get simple (shallow) “donut scope”
So our last step for donut scoping completion is being able to go beyond one DOM layer. Luckily, last year we were gifted the @scope at-rule (you can read more about it in its Almanac entry). In a nutshell, it lets us select a subtree in the DOM where our styles will be scoped, so no more global scope!
@scope (.parent) { /* Styles written here will only affect .parent */ }What’s better, we can leave slots inside the subtree we selected (usually called the scope root). In this case, we would want to style the .parent element without scoping .content:
@scope (.parent) to (.content) { /* Styles written here will only affect .parent but skip .content*/ }And what’s better, it detects every .content element inside .parent, no matter how nested it may be. So we don’t need to worry about where we are writing our slots. In the last example, we could instead write the following style to change the text color of the element in .parent without touching .content:
body { color: blue; } @scope (.parent) to (.content) { h2, p, span, a { color: orange; } }While it may seem inconvenient to list all the elements we are going to change, we can’t use something like the universal selector (*) since it would mess up the scoping of nested slots. In this example, it would leave the nested .content out of scope, but not its container. Since the color property inherits, the nested .content would change colors regardless!
And voilà! Both .content slots are inside our scoped donut holes:
CodePen Embed FallbackShallow scoping is still possible with this method, we would just have to rewrite our slot selector so that only direct .content descendants of .parent are left out of the scope. However, we have to use the :scope selector, which refers back to the scoping root, or .parent in this case:
@scope (.parent) to (:scope > .content) { * { color: orange; } }We can use the universal selector in this instance since it’s shallow scoping.
CodePen Embed Fallback ConclusionDonut scoping, a wannabe feature coined back in 2011 has finally been brought to life in the year 2024. It’s still baffling how it appeared to sit in the back of our minds until recently, as just another consequence of CSS Global Scope, while it had so many quirks by itself. It would be unfair, however, to say that it went under everyone’s radars since the CSSWG (the people behind writing the spec for new CSS features) clearly had the intention to address it when writing the spec for the @scope at-rule.
Whatever it may be, I am grateful we can have true donut scoping in our CSS. To some degree, we still have to wait for Firefox to support it. 😉
This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
DesktopChromeFirefoxIEEdgeSafari118NoNo11817.4Mobile / TabletAndroid ChromeAndroid FirefoxAndroidiOS Safari131No13117.4Solved by CSS: Donuts Scopes originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
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.