Developer News
It’s Time To Talk About “CSS5”
Ever search for CSS info and run into some article — perhaps even one or a dozen on this site — that looks promising until you realize it was published when dinosaurs roamed the planet? The information is good, but maybe isn’t the best reflection of modern best practices?
I’ve wondered that a bunch. And so has Chris. But so has Brecht De Ruyte and he’s actually doing something about it, along with other folks who make up the W3C CSS-Next community group. I worked with him on this article for Smashing Magazine and was stoked to see how much discussion, thought, and intention have gone into “versioning” CSS.
The idea? We’d “skip” CSS4, so to speak, slapping a CSS4 label on a lot of what’s released since CSS3:
CSS3 (~2009-2012):
Level 3 CSS specs as defined by the CSSWG. (immutable)
CSS4 (~2013-2018):
Essential features that were not part of CSS3 but are already a fundamental part of CSS..
From there?
CSS5 (~2019-2024):
Newer features whose adoption is steadily growing.
CSS6 (~2025+):
Early-stage features that are planned for future CSS
The most important part of the article, though, is that you (yes, you) can help the CSS-Next community group.
We also want you to participate. Anyone is welcome to join the CSS-Next group and we could certainly use help brainstorming ideas. There’s even an incubation group that conducts a biweekly hour-long session that takes place on Mondays at 8:00 a.m. Pacific Time (2:00 p.m. GMT).
It’s Time To Talk About “CSS5” originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Intent-driven User Interfaces
Increasingly when I see designers defaulting to more Ul controls and form elements in software interface designs, I encourage them to consider the implications of intent-driven instructions. Here's why...
For years l've used this image of Adobe Illustrator's user interface evolution to highlight the continuous march of "more features, more Ul" that drives nearly every software company's releases. The end result for end users is more functions they don't know about and don't use. Not great.
So what's the alternative? Perhaps something like Christian Cantrell's Photoshop assistant demos. In this series of videos, Christian uses natural language instructions connected to Photoshop's APIs to do things like mask the subject of a series of photos, blur the background in images, create layers and more. All without needing to know how and without clicking a bunch of windows, icons, menus, and pointers (WIMP).
Intent-driven instructions to mask the subject of multiple images in Photoshop:
Intent-driven instructions to mask the blur the backgrounds of multiple images in Photoshop:
Intent-driven instructions to create layers and objects in Photoshop:
While these kinds of interactions won't immediately replace conventional graphical user interface controls, it's pretty clear they enable a new way of control software with hundreds of features... just tell it what you want to do.
Let’s make a simpler, more accessible web
Christian Heilmann gave this talk at Typo3 Developer Days. I’m linking it up because it strikes an already stricken nerve in me. The increasing complexity of web development has an inverse relationship with the decreasing number of entry points for those getting into web development.
I love how Christian compares two hypothetical development stacks.
Thenindex.html
Now- Get the right editor with all the right extensions
- Set up your terminal with the right font and all the cool dotfiles
- Install framework flügelhorn.js with bundler wolperdinger.io
- Go to the terminal and run packagestuff –g install
- Look at all the fun warning messages and update dependencies
- Doesn’t work? Go SUDO, all the cool kids are …
- Don’t bother with the size of the modules folder
- Learn the abstraction windfarm.css – it does make you so much more effective
- Use the templating language funsocks – it is much smaller than HTML
- Check out the amazing hello world example an hour later…
He’s definitely a bit glib, but the point is solid. Things are more complex today than they were, say, ten years ago. I remember struggling with Grunt back then and thinking I’d never get it right. I did eventually, and my IDE was never the same after that.
It’s easy to get swept up in the complexity, even for those with experience in the field:
This world is unfortunately becoming lost or, at least, degraded — not because it is no longer possible to view the source of a webpage, but because that source is often inscrutable, even on simple webpages.
— Pixel Envy “A View Source Web”Christian’s post reminds me that the essence of the web is not only still alive but getting better every day:
- Browsers are constantly updated.
- The web standardisation process is much faster than it used to be.
- We don’t all need to build the next killer app. Many a framework promises scaling to infinity and only a few of us will ever need that.
He goes on to suggest many ways to remove complexity and abstractions from a project. My biggest takeaway is captured by a single headline:
The web is built on resilient technologies – we just don’t use them
Which recalls what Molly White said earlier this year that there’s always an opportunity to swing the pendulum back:
The thing is: none of this is gone. Nothing about the web has changed that prevents us from going back. If anything, it’s become a lot easier. We can return. Better, yet: we can restore the things we loved about the old web while incorporating the wonderful things that have emerged since, developing even better things as we go forward, and leaving behind some things from the early web days we all too often forget when we put on our rose-colored glasses.
We can return. We can restore all the things. So, tell me: do you take the red pill or the blue one?
Let’s make a simpler, more accessible web originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
CSS Olympic Rings
It was a few years ago during the 2020 Olympics in Tokyo 2020 that I made a demo of animated 3D Olympic rings. I like it, it looks great, and I love the effect of the rings crossing each other.
CodePen Embed FallbackBut the code itself is kind of old. I wrote it in SCSS, and crookedly at that. I know it could be better, at least by modern standards.
So, I decided to build the demo again from scratch in honor of this year’s Olympics. I’m writing vanilla CSS this time, leveraging modern features like trigonometric functions for fewer magic numbers and the relative color syntax for better color management. The kicker, turns out, is that the new demo winds up being more efficient with fewer lines of code than the old SCSS version I wrote in 2020!
Look at the CSS tab in that first demo again because we’ll wind up with something vastly different — and better — with the approach we’re going to use together. So, let’s begin!
The markupWe’ll use layers to create the 3D effect. These layers are positioned one after the other (on the z-axis) to get the depth of the 3D object which, in our case, is a ring. The combination of the shape, size, and color of each layer — plus the way they vary from layer to layer — is what creates the full 3D object.
In this case, I’m using 16 layers where each layer is a different shade (with the darker layers stacked at the back) to get a simple lighting effect, and using the size and thickness of each layer to establish a round, circular shape.
As far as HTML goes, we need five <div> elements, one for each ring, where each <div> contains 16 elements that act as the layers, which I’m wrapping in <i> tags. Those five rings we’ll put in a parent container to hold things together. We’ll give the parent container a .rings class and each ring, creatively, a .ring class.
This is an abbreviated version of the HTML showing how that comes together:
<div class="rings"> <div class="ring"> <i style="--i: 1;"></i> <i style="--i: 2;"></i> <i style="--i: 3;"></i> <i style="--i: 4;"></i> <i style="--i: 5;"></i> <i style="--i: 6;"></i> <i style="--i: 7;"></i> <i style="--i: 8;"></i> <i style="--i: 9;"></i> <i style="--i: 10;"></i> <i style="--i: 11;"></i> <i style="--i: 12;"></i> <i style="--i: 13;"></i> <i style="--i: 14;"></i> <i style="--i: 15;"></i> <i style="--i: 16;"></i> </div> <!-- 4 more rings... --> </div>Note the --i custom property I’ve dropped on the style attribute of each <i> element:
<i style="--i: 1;"></i> <i style="--i: 2;"></i> <i style="--i: 3;"></i> <!-- etc. -->We’re going to use --i to calculate each layer’s position, size, and color. That’s why I’ve set their values as integers in ascending order — those will be multipliers for arranging and styling each layer individually.
Pro tip: You can avoid writing the HTML for each and every layer by hand if you’re working on an IDE that supports Emmet. But if not, no worries, because CodePen does! Enter the following into your HTML editor then press the Tab key on your keyboard to expand it into 16 layers: i*16[style="--i: $;"]
The (vanilla) CSSLet’s start with the parent .rings container for now will just get a relative position. Without relative positioning, the rings would be removed from the document flow and wind up off the page somewhere when setting absolute positioning on them.
.rings { position: relative; } .ring { position: absolute; }Let’s do the same with the <i> elements, but use CSS nesting to keep the code compact. We’ll throw in border-radius while we’re at it to clip the boxy edges to form perfect circles.
.rings { position: relative; } .ring { position: absolute; i { position: absolute; border-radius: 50%; } }The last piece of basic styling we’ll apply before moving on is a custom property for the --ringColor. This’ll make coloring the rings fairly straightforward because we can write it once, and then override it on a layer-by-layer basis. We’re declaring --ringColor on the border property because we only want coloration on the outer edges of each layer rather than filling them in completely with background-color:
.rings { position: relative; } .ring { position: absolute; --ringColor: #0085c7; i { position: absolute; inset: -100px; border: 16px var(--ringColor) solid; border-radius: 50%; } }Did you notice I snuck something else in there? That’s right, the inset property is also there and set to a negative value of 100px. That might look a little strange, so let’s talk about that first as we continue styling our work.
Negative insettingSetting a negative value on the inset property means that the layer’s position falls outside the .ring element. So, we might think of it more like an “outset” instead. In our case, the .ring has no size as there are no content or CSS properties to give it dimensions. That means the layer’s inset (or rather “outset”) is 100px in each direction, resulting in a .ring that is 200×200 pixels.
Let’s check in with what we have so far:
CodePen Embed Fallback Positioning for depthWe’re using the layers to create the impression of depth. We do that by positioning each of the 16 layers along the z-axis, which stacks elements from front to back. We’ll space each one a mere 2px apart — that’s all the space we need to create a slight visual separation between each layer, giving us the depth we’re after.
Remember the --i custom property we used in the HTML?
<i style="--i: 1;"></i> <i style="--i: 2;"></i> <i style="--i: 3;"></i> <!-- etc. -->Again, those are multipliers to help us translate each layer along the z-axis. Let’s create a new custom property that defines the equation so we can apply it to each layer:
i { --translateZ: calc(var(--i) * 2px); }What do we apply it to? We can use the CSS transform property. This way, we can rotate the layers vertically (i.e., rotateY()) while translating them along the z-axis:
i { --translateZ: calc(var(--i) * 2px); transform: rotateY(-45deg) translateZ(var(--translateZ)); } Color for shadingFor color shading, we’ll darken the layers according to their position so that the layers get darker as we move from the front of the z-axis to the back. There are a few ways to do it. One is dropping in another black layer with decreasing opacity. Another is modifying the “lightness” channel in a hsl() color function where the value is “lighter” up front and incrementally darker towards the back. A third option is playing with the layer’s opacity, but that gets messy.
Even though we have those three approaches, I think the modern CSS relative color syntax is the best way to go. We’ve already defined a default --ringColor custom property. We can put it through the relative color syntax to manipulate it into other colors for each ring <i> layer.
First, we need a new custom property we can use to calculate a “light” value:
.ring { --ringColor: #0085c7; i { --light: calc(var(--i) / 16); border: 16px var(--ringColor) solid; } }We’ll use the calc()-ulated result in another custom property that puts our default --ringColor through the relative color syntax where the --light custom property helps modify the resulting color’s lightness.
.ring { --ringColor: #0085c7; i { --light: calc(var(--i) / 16); --layerColor: rgb(from var(--ringColor) calc(r * var(--light)) calc(g * var(--light)) calc(b * var(--light))); border: 16px var(--ringColor) solid; } }That’s quite an equation! But it only looks complex because the relative color syntax needs arguments for each channel in the color (RGB) and we’re calculating each one.
rgb(from origin-color channelR channelG channelB)As far as the calculations go, we multiply each RGB channel by the --light custom property, which is a number between 0 and 1 divided by the number of layers, 16.
Time for another check to see where we’re at:
CodePen Embed Fallback Creating the shapeTo get the circular ring shape, we’ll set the layer’s size (i.e., thickness) with the border property. This is where we can start using trigonometry in our work!
We want the thickness of each ring to be a value between 0deg to 180deg — since we’re only actually making half of a circle — so we will divide 180deg by the number of layers, 16, which comes out to 11.25deg. Using the sin() trigonometric function (which is equivalent to the opposite and hypotenuse sides of a right angle), we get this expression for the layer’s --size:
--size: calc(sin(var(--i) * 11.25deg) * 16px);So, whatever --i is in the HTML, it acts as a multiplier for calculating the layer’s border thickness. We have been declaring the layer’s border like this:
i { border: 16px var(--ringColor) solid; )Now we can replace the hard-coded 16px value with --size calculation:
i { --size: calc(sin(var(--i) * 11.25deg) * 16px); border: var(--size) var(--layerColor) solid; )But! As you may have noticed, we aren’t changing the layer’s size when we change its border width. As a result, the round profile only appears on the layer’s inner side. The key thing here is understanding that setting the --size with the inset property which means it does not affect the element’s box-sizing. The result is a 3D ring for sure, but most of the shading is buried.
⚠️ Auto-playing mediaWe can bring the shading out by calculating a new inset for each layer. That’s kind of what I did in the 2020 version, but I think I’ve found an easier way: add an outline with the same border values to complete the arc on the outer side of the ring.
i { --size: calc(sin(var(--i) * 11.25deg) * 16px); border: var(--size) var(--layerColor) solid; outline: var(--size) var(--layerColor) solid; }We have a more natural-looking ring now that we’ve established an outline:
CodePen Embed Fallback Animating the ringsI had to animate the ring in that last demo to compare the ring’s shading before and after. We’ll use that same animation in the final demo, so let’s break down how I did that before we add the other four rings to the HTML
I’m not trying to do anything fancy; I’m just setting the rotation on the y-axis from -45deg to 45deg (the translateZ value remains constant).
@keyframes ring { from { transform: rotateY(-45deg) translateZ(var(--translateZ, 0)); } to { transform: rotateY(45deg) translateZ(var(--translateZ, 0)); } }As for the animation property, I’ve given named it ring , and a hard-coded (at least for now) a duration of 3s, that loops infinitely. Setting the animation’s timing function with ease-in-out and alternate, respectively, gives us a smooth back-and-forth motion.
i { animation: ring 3s infinite ease-in-out alternate; }That’s how the animation works!
Adding more ringsNow we can add the remaining four rings to the HTML. Remember, we have five rings total and each ring contains 16 <i> layers. It could look as simple as this:
<div class="rings"> <div class="ring"> <!-- <i> layers --> </div> <div class="ring"> <!-- <i> layers --> </div> <div class="ring"> <!-- <i> layers --> </div> <div class="ring"> <!-- <i> layers --> </div> <div class="ring"> <!-- <i> layers --> </div> </div>There’s something elegant about the simplicity of this markup. And we could use the CSS nth-child() pseudo-selector to select them individually. I like being a bit more declarative than that and am going to give each .ring and additional class we can use to explicitly select a given ring.
<div class="rings"> <div class="ring ring__1"> <!-- layers --> </div> <div class="ring ring__2"> <!-- layers --> </div> <div class="ring ring__3"> <!-- layers --> </div> <div class="ring ring__4"> <!-- layers --> </div> <div class="ring ring__5"> <!-- layers --> </div> </div>Our task now is to adjust each ring individually. Right now, everything looks like the first ring we made together. We’ll use the unique classes we just set in the HTML to give them their own color, position, and animation duration.
The good news? We’ve been using custom properties this entire time! All we have to do is update the values in each ring’s unique class.
.ring { &.ring__1 { --ringColor: #0081c8; --duration: 3.2s; --translate: -240px, -40px; } &.ring__2 { --ringColor: #fcb131; --duration: 2.6s; --translate: -120px, 40px; } &.ring__3 { --ringColor: #444444; --duration: 3.0s; --translate: 0, -40px; } &.ring__4 { --ringColor: #00a651; --duration: 3.4s; --translate: 120px, 40px; } &.ring__5 { --ringColor: #ee334e; --duration: 2.8s; --translate: 240px, -40px; } }If you’re wondering where those --ringColor values came from, I based them on the International Olympic Committee’s documented colors. Each --duration is slightly offset from one another to stagger the movement between rings, and the rings are --translate‘d 120px apart and then staggered vertically by alternating their position 40px and -40px.
Let’s apply the translation stuff to the .ring elements:
.ring { transform: translate(var(--translate)); }Earlier, we set the animation’s duration to a hard-coded three seconds:
i { animation: ring 3s infinite ease-in-out alternate; }This is the time to replace that with a custom property that calculates the duration for each ring separately.
i { animation: ring var(--duration) -10s infinite ease-in-out alternate; }Whoa, whoa! What’s the -10s value doing in there? Even though each ring layer is set to animate for a different duration, the starting angle of the animations is all the same. Adding a constant negative delay on changing durations will make sure that each ring’s animation starts at a different angle.
Now we have something that is almost finished:
CodePen Embed Fallback Some final touchesWe’re at the final stretch! The animation looks pretty great as-is, but I want to add two more things. The first one is a small-10deg “tilt” on the x-axis of the parent .rings container. This will make it look like we’re viewing things from a higher perspective.
.rings { rotate: x -10deg; }The second finishing touch has to do with shadows. We can really punctuate the 3D depth of our work and all it takes is selecting the .ring element’s ::after pseudo-element and styling it like a shadow.
First, we’ll set the width of the pseudos’ border and outline to a constant (24px) while setting the color to a semi-transparent black (#0003). Then we’ll translate them so they appear to be further away. We’ll also inset them so they line up with the actual rings. Basically, we’re shifting the pseudo-elements around relative to the actual element.
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; } }The pseudos don’t look very shadow-y at the moment. But they will if we blur() them a bit:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); } }The shadows are also pretty box-y. Let’s make sure they’re round like the rings:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); border-radius: 50%; } }Oh, and we ought to set the same animation on the pseudo so that the shadows move in harmony with the rings:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); border-radius: 50%; animation: ring var(--duration) -10s infinite ease-in-out alternate; } } Final demoLet’s stop and admire our completed work:
CodePen Embed FallbackAt the end of the day, I’m really happy with the 2024 version of the Olympic rings. The 2020 version got the job done and was probably the right approach for that time. But with all of the features we’re getting in modern CSS today, I had plenty of opportunities to improve the code so that it is not only more efficient but more reusable — for example, this could be used in another project and “themed” simply by updating the --ringColor custom property.
Ultimately, this exercise proved to me the power and flexibility of modern CSS. We took an existing idea with complexities and recreated it with simplicity and elegance.
CSS Olympic Rings originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Distraction Control for the Web
Browsing the Web on your smartphone these days can feel like a gauntlet: accept this cookie consent, close this newsletter promo, avoid this app install banner. This morass of attention-seeking actions makes it hard to focus on content. Enter Apple's Distraction Control feature.
There's more than 7 billion active smartphones on the planet. This is the Web they are getting.
I won't get into how the Web became a minefield of pop-ups, banners, overlays, modals, and other forms of annoyance. For that you can take a look at my Mind the Gap presentation which goes into depth on why and what designers can do about it. But it's pretty clear the average mobile Web experience sucks.
And when things suck, people usually decide to do something about it. In this case, with iOS 18, Apple is giving average folks a chance to fight back with Distraction Control. When turned on, this new feature allows anyone to remove distracting elements on Web pages complete with a satisfying animation.
Newsletter pop-up? Boom, gone. Mobile app banner? Boom. Interstitial ad? Boom. Is it perfect? No. Elements might come back after you remove them if the page is reloaded. Accessing the control takes a few taps. But it's a way for people to fight back against Web clutter and we need more.
(Hyper) Links About (Hyper) Links
Heydon on the virtues of hyperlinking hypertext in an anchor element:
Sometimes, the <a> is referred to as a hyperlink, or simply a link. But it is not one of these and people who say it is one are technically wrong (the worst kind of wrong).
[…]
An <a> is an interactive element (well, it is if it has an href). The text inside an interactive element is sometimes referred to as a label since it should tell you what the element does. Since anchors take you places on the web, the text should tell you where you would be going or what you can do there.
[…]
Web developers and content editors, the world over, make the mistake of not making text that describes a link actually go inside that link. This is collosally [sic] unfortunate, given it’s the main thing to get right when writing hypertext.
As far as where that anchor hyperlinks to, Jim Nielsen back in 2003 discussed a bunch of considerations that go into designing URLs. More recently, he’s mused on the the potential of well-designed URLs to change — or more accurately, the potential of humans to change things:
If a slug is going to be human-friendly, i.e. human-readable, then it’s going to contain information that is subject to change because humans make errors.
Swapping the contents of a URL is a breaking change. If we were to start with a wonderful URL like, say:
<a href=“css-tricks.com/almanac”>…but decide that we now like “Docs” instead of “Almanac” then we might do this:
<a href=“css-tricks.com/docs”>Naturally, we’d drop some sorta redirect on the server so that anyone attempting to hit /almanac is automatically directed to /docs instead. But now we’ve got a form of technical debt to maintain that may not be any more dangerous than walking and chewing gum at the same time, but could become a mouthful much later. We’ve got a gazillion redirects on CSS-Tricks for a gazillion different reasons, most often for totally human reasons like typos. Remember the CSS-Tricks Chronicles we used to write? Botching the Roman numeral numbering system on those was standard fare. Look at the very last edition from 2001, titled “CSS-Tricks Choronicles XLI” and its URL:
https://css-tricks.com/css-tricks-chronicle-xxxxi/🥸
I’ve been thinking about this a lot while attempting to organize the 7,000 some-odd articles on this site. For years, we’ve maintained a “flat” structure in the sense that the title of an article becomes the URL (after, perhaps, with some light editing):
<a href=“css-tricks.com/geoff-is-on-another-dumb-rant”>But I’m starting to think about the content on this site in terms of type rather than title alone. For example, we’ve always had “articles” on this site with a smattering of “links” sprinkled in alongside Almanac “entries” and “guides” among other categories of content. We’ve just never reflected that in our URLs because, well, the design is flat. Adding another layer for the type of content borks the original URL!
<a href=“css-tricks.com/soapbox/geoff-is-on-another-dumb-rant”>Jay Hoffman has been thinking about this, too.
A dead link may not seem like it means very much, even in the aggregate. But they are. One-way links, the way they exist on the web where anyone can link to anything, is what makes the web universal. In fact, the first name for URL’s was URI’s, or Universal Resource Identifier. It’s right there in the name. And as Berners-Lee once pointed out, “its universality is essential.”
[…]
Time and time again, when the web goes into crisis and part of it is lost, the Internet Archive and similar efforts come to the rescue. But even the Internet Archive is having a hard time protecting against a barrage of link rot we can’t seem to get away from.
All of this dovetails into recent reporting that Google has decided to sunset its URL shortener. All of those goo.gl URLs accumulated since the shortener was introduced in 2018?
Any developers using links built with the Google URL Shortener in the form https://goo.gl/* will be impacted, and these URLs will no longer return a response after August 25th, 2025. We recommend transitioning these links to another URL shortener provider.
There’s some minutiae of consolation for Google itself:
Note that goo.gl links generated via Google apps (such as Maps sharing) will continue to function.
To be clear, this move is less a form of link rot than it is a straight-up pruning to cut things off. If link rot is akin to allowing your hair to go gray, then deprecating Google’s URL shortener is a total head shave. Nick Heer believes there’s a good side to it, however:
In principle, I support this deprecation because it is confusing and dangerous for Google’s own shortened URLs to have the same domain as ones created by third-party users. But this is a Google-created problem because it designed its URLs poorly. It should have never been possible for anyone else to create links with the same URL shortener used by Google itself.
I tend to agree. The whole situation is a Rosemary’s Baby predicament presenting two terribly uncomfortable choices. The right uncomfortable decision was made, but we still have to deal with the repercussions of wiping out part of the web’s context.
Heydon’s post led me down this rabbit trail, so I’ll link it up here for you to take a hike with it.
(Hyper) Links About (Hyper) Links originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
HTML Web Components Make Progressive Enhancement and CSS Encapsulation Easier!
I have to thank Jeremy Keith and his wonderfully insightful article from late last year that introduced me to the concept of HTML Web Components. This was the “a-ha!” moment for me:
When you wrap some existing markup in a custom element and then apply some new behaviour with JavaScript, technically you’re not doing anything you couldn’t have done before with some DOM traversal and event handling. But it’s less fragile to do it with a web component. It’s portable. It obeys the single responsibility principle. It only does one thing but it does it well.
Until then, I’d been under the false assumption that all web components rely solely on the presence of JavaScript in conjunction with the rather scary-sounding Shadow DOM. While it is indeed possible to author web components this way, there is yet another way. A better way, perhaps? Especially if you, like me, advocate for progressive enhancement. HTML Web Components are, after all, just HTML.
While it’s outside the exact scope of what we’re discussing here, Andy Bell has a recent write-up that offers his (excellent) take on what progressive enhancement means.
Let’s look at three specific examples that show off what I think are the key features of HTML Web Components — CSS style encapsulation and opportunities for progressive enhancement — without being forced to depend on JavaScript to work out of the box. We will most definitely use JavaScript, but the components ought to work without it.
The examples can all be found in my Web UI Boilerplate component library (built using Storybook), along with the associated source code in GitHub.
Example 1: <webui-disclosure> Live demoI really like how Chris Ferdinandi teaches building a web component from scratch, using a disclosure (show/hide) pattern as an example. This first example extends his demo.
Let’s start with the first-class citizen, HTML. Web components allow us to establish custom elements with our own naming, which is the case in this example with a <webui-disclosure> tag we’re using to hold a <button> designed to show/hide a block of text and a <div> that holds the <p> of text we want to show and hide.
<webui-disclosure data-bind-escape-key data-bind-click-outside > <button type="button" class="button button--text" data-trigger hidden > Show / Hide </button> <div data-content> <p>Content to be shown/hidden.</p> </div> </webui-disclosure>If JavaScript is disabled or doesn’t execute (for any number of possible reasons), the button is hidden by default — thanks to the hidden attribute on it— and the content inside of the div is simply displayed by default.
Nice. That’s a really simple example of progressive enhancement at work. A visitor can view the content with or without the <button>.
I mentioned that this example extends Chris Ferdinandi’s initial demo. The key difference is that you can close the element either by clicking the keyboard’s ESC key or clicking anywhere outside the element. That’s what the two [data-attribute]s on the <webui-disclosure tag are for.
We start by defining the custom element so that the browser knows what to do with our made-up tag name:
customElements.define('webui-disclosure', WebUIDisclosure);Custom elements must be named with a dashed-ident, such as <my-pizza> or whatever, but as Jim Neilsen notes, by way of Scott Jehl, that doesn’t exactly mean that the dash has to go between two words.
I typically prefer using TypeScript for writing JavaScript to help eliminate stupid errors and enforce some degree of “defensive” programming. But for the sake of simplicity, the structure of the web component’s ES Module looks like this in plain JavaScript:
default class WebUIDisclosure extends HTMLElement { constructor() { super(); this.trigger = this.querySelector('[data-trigger]'); this.content = this.querySelector('[data-content]'); this.bindEscapeKey = this.hasAttribute('data-bind-escape-key'); this.bindClickOutside = this.hasAttribute('data-bind-click-outside'); if (!this.trigger || !this.content) return; this.setupA11y(); this.trigger?.addEventListener('click', this); } setupA11y() { // Add ARIA props/state to button. } // Handle constructor() event listeners. handleEvent(e) { // 1. Toggle visibility of content. // 2. Toggle ARIA expanded state on button. } // Handle event listeners which are not part of this Web Component. connectedCallback() { document.addEventListener('keyup', (e) => { // Handle ESC key. }); document.addEventListener('click', (e) => { // Handle clicking outside. }); } disconnectedCallback() { // Remove event listeners. } }Are you wondering about those event listeners? The first one is defined in the constructor() function, while the rest are in the connectedCallback() function. Hawk Ticehurst explains the rationale much more eloquently than I can.
This JavaScript isn’t required for the web component to “work” but it does sprinkle in some nice functionality, not to mention accessibility considerations, to help with the progressive enhancement that allows the <button> to show and hide the content. For example, JavaScript injects the appropriate aria-expanded and aria-controls attributes enabling those who rely on screen readers to understand the button’s purpose.
That’s the progressive enhancement piece to this example.
For simplicity, I have not written any additional CSS for this component. The styling you see is simply inherited from existing global scope or component styles (e.g., typography and button).
However, the next example does have some extra scoped CSS.
Example 2: <webui-tabs>That first example lays out the progressive enhancement benefits of HTML Web Components. Another benefit we get is that CSS styles are encapsulated, which is a fancy way of saying the CSS doesn’t leak out of the component. The styles are scoped purely to the web component and those styles will not conflict with other styles applied to the current page.
Let’s turn to a second example, this time demonstrating the style encapsulating powers of web components and how they support progressive enhancement in user experiences. We’ll be using a tabbed component for organizing content in “panels” that are revealed when a panel’s corresponding tab is clicked — the same sort of thing you’ll find in many component libraries.
Live demoStarting with the HTML structure:
<webui-tabs> <div data-tablist> <a href="#tab1" data-tab>Tab 1</a> <a href="#tab2" data-tab>Tab 2</a> <a href="#tab3" data-tab>Tab 3</a> </div> <div id="tab1" data-tabpanel> <p>1 - Lorem ipsum dolor sit amet consectetur.</p> </div> <div id="tab2" data-tabpanel> <p>2 - Lorem ipsum dolor sit amet consectetur.</p> </div> <div id="tab3" data-tabpanel> <p>3 - Lorem ipsum dolor sit amet consectetur.</p> </div> </webui-tabs>You get the idea: three links styled as tabs that, when clicked, open a tab panel holding content. Note that each [data-tab] in the tab list targets an anchor link matching a tab panel ID, e.g., #tab1, #tab2, etc.
We’ll look at the style encapsulation stuff first since we didn’t go there in the last example. Let’s say the CSS is organized like this:
webui-tabs { [data-tablist] { /* Default styles without JavaScript */ } [data-tab] { /* Default styles without JavaScript */ } [role='tablist'] { /* Style role added by JavaScript */ } [role='tab'] { /* Style role added by JavaScript */ } [role='tabpanel'] { /* Style role added by JavaScript */ } }See what’s happening here? We have two style rules — [data-tablist] and [data-tab] — that contain the web component’s default styles. In other words, these styles are there regardless of whether JavaScript loads or not. Meanwhile, the other three style rules are selectors that are injected into the component as long as JavaScript is enabled and supported. This way, the last three style rules are only applied if JavaScript plops the **role** attribute on those elements in the HTML. Right there, we’re already supplying a touch of progressive enhancement by setting styles only when JavasScript is needed.
All these styles are fully encapsulated, or scoped, to the <webui-tabs> web component. There is no “leakage” so to speak that would bleed into the styles of other web components, or even to anything else on the page within the global scope. We can even choose to forego classnames, complex selectors, and methodologies like BEM in favour of simple descendent selectors for the component’s children, allowing us to write styles more declaratively on semantic elements.
Quickly: “Light” DOM versus Shadow DOMFor most web projects, I generally prefer to bundle CSS (including the web component Sass partials) into a single CSS file so that the component’s default styles are available in the global scope, even if the JavaScript doesn’t execute.
However, it is possible to import a stylesheet via JavaScript that is only consumed by this web component if JavaScript is available:
import styles from './styles.css'; class WebUITabs extends HTMLElement { constructor() { super(); this.adoptedStyleSheets = [styles]; } } customElements.define('webui-tabs', WebUITabs);Alternatively, we could inject a <style> tag containing the component’s styles instead:
class WebUITabs extends HTMLElement { connectedCallback() { this.attachShadow({ mode: 'open' }); // Required for JavaScript access this.shadowRoot.innerHTML = ` <style> <!-- styles go here --> </style> // etc. `; } } customElements.define('webui-tabs', WebUITabs);Whichever method you choose, these styles are scoped directly to the web component, preventing component styles from leaking out, but allowing global styles to be inherited.
Now consider this simple example. Everything we write in between the component’s opening and closing tags is considered part of the “Light” DOM.
<my-web-component> <!-- This is Light DOM --> <div> <p>Some content... styles are inherited from the global scope</p> </div> ----------- Shadow DOM Boundary ------------- | <!-- Anything injected by JavaScript --> | --------------------------------------------- </my-web-component>Dave Rupert has an excellent write-up that makes it really easy to see how external styles are able to “pierce” the Shadow DOM and select an element in the Light DOM. Notice how the <button> element that is written in between the custom element’s tags receives the button selector’s styles in the global CSS, while the <button> injected via JavaScript is left untouched.
If we want to style the Shadow DOM <button> we’d have to do that with internal styles like the examples above for importing a stylesheet or injecting an inline <style> block.
That doesn’t mean that all CSS style properties are blocked by the Shadow DOM. In fact, Dave outlines 37 properties that web components inherit, mostly along the lines of text, list, and table formatting.
Progressively enhance the tabbed component with JavaScriptEven though this second example is more about style encapsulation, it’s still a good opportunity to see the progressive enhancement we get practically for free from web components. Let’s step into the JavaScript now so we can see how we can support progressive enhancement. The full code is quite lengthy, so I’ve abbreviated things a bit to help make the points a little clearer.
default class WebUITabs extends HTMLElement { constructor() { super(); this.tablist = this.querySelector('[data-tablist]'); this.tabpanels = this.querySelectorAll('[data-tabpanel]'); this.tabTriggers = this.querySelectorAll('[data-tab]'); if ( !this.tablist || this.tabpanels.length === 0 || this.tabTriggers.length === 0 ) return; this.createTabs(); this.tabTriggers.forEach((tabTrigger, index) => { tabTrigger.addEventListener('click', (e) => { this.bindClickEvent(e); }); tabTrigger.addEventListener('keydown', (e) => { this.bindKeyboardEvent(e, index); }); }); } createTabs() { // 1. Hide all tabpanels initially. // 2. Add ARIA props/state to tabs & tabpanels. } bindClickEvent(e) { e.preventDefault(); // Show clicked tab and update ARIA props/state. } bindKeyboardEvent(e, index) { e.preventDefault(); // Handle keyboard ARROW/HOME/END keys. } } customElements.define('webui-tabs', WebUITabs);The JavaScript injects ARIA roles, states, and props to the tabs and content blocks for screen reader users, as well as extra keyboard bindings so we can navigate between tabs with the keyboard; for example, the TAB key is reserved for accessing the component’s active tab and any focusable content inside the active tabpanel, and the tabs can be traversed with the ARROW keys. So, if JavaScript fails to load, the default experience is still an accessible one where the tabs still anchor link to their respective panels, and those panels naturally stack vertically, one on top of the other.
And if JavaScript is enabled and supported? We get an enhanced experience, complete with updated accessibility considerations.
Example 3: <webui-ajax-loader> Live demoThis final example differs from the previous two in that it is entirely generated by JavaScript, and uses the Shadow DOM. This is because it is only used to indicate a “loading” state for Ajax requests, and is therefore only needed when JavaScript is enabled.
The HTML markup is just the opening and closing component tags:
<webui-ajax-loader></webui-ajax-loader>The simplified JavaScript structure:
default class WebUIAjaxLoader extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <svg role="img" part="svg"> <title>loading</title> <circle cx="50" cy="50" r="47" /> </svg> `; } } customElements.define('webui-ajax-loader',WebUIAjaxLoader);Notice right out of the gate that everything in between the <webui-ajax-loader> tags is injected with JavaScript, meaning it’s all in the Shadow DOM, encapsulated from other scripts and styles not directly bundled with the component.
But also notice the part attribute that’s set on the <svg> element. Here’s where we’ll zoom in:
<svg role="img" part="svg"> <!-- etc. --> </svg>That’s yet another way we have to style the custom element: named parts. Now we can style that SVG from outside of the template literal we used to establish the element. There’s a ::part pseudo-selector to make that happen:
webui-ajax-loader::part(svg) { // Shadow DOM styles for the SVG... }And here’s something cool: that selector can access CSS custom properties, whether they are scoped globally or locally to the element.
webui-ajax-loader { --fill: orangered; } webui-ajax-loader::part(svg) { fill: var(--fill); }As far as progressive enhancement goes, JavaScript supplies all of the HTML. That means the loader is only rendered if JavaScript is enabled and supported. And when it is, the SVG is added, complete with an accessible title and all.
Wrapping upThat’s it for the examples! What I hope is that you now have the same sort of epiphany that I had when reading Jeremy Keith’s post: HTML Web Components are an HTML-first feature.
Of course, JavaScript does play a big role, but only as big as needed. Need more encapsulation? Want to sprinkle in some UX goodness when a visitor’s browser supports it? That’s what JavaScript is for and that’s what makes HTML Web Components such a great addition to the web platform — they rely on vanilla web languages to do what they were designed to do all along, and without leaning too heavily on one or the other.
HTML Web Components Make Progressive Enhancement and CSS Encapsulation Easier! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.