Tech News

Tailwind’s @apply Feature is Better Than it Sounds

Css Tricks - Thu, 04/10/2025 - 2:39am

By this point, it’s not a secret to most people that I like Tailwind.

But, unknown to many people (who often jump to conclusions when you mention Tailwind), I don’t like vanilla Tailwind. In fact, I find most of it horrible and I shall refrain from saying further unkind words about it.

But I recognize and see that Tailwind’s methodology has merits — lots of them, in fact — and they go a long way to making your styles more maintainable and performant.

Today, I want to explore one of these merit-producing features that has been severely undersold — Tailwind’s @apply feature.

What @apply does

Tailwind’s @apply features lets you “apply” (or simply put, copy-and-paste) a Tailwind utility into your CSS.

Most of the time, people showcase Tailwind’s @apply feature with one of Tailwind’s single-property utilities (which changes a single CSS declaration). When showcased this way, @apply doesn’t sound promising at all. It sounds downright stupid. So obviously, nobody wants to use it.

/* Input */ .selector { @apply p-4; } /* Output */ .selector { padding: 1rem; }

To make it worse, Adam Wathan recommends against using @apply, so the uptake couldn’t be worse.

Confession: The `apply` feature in Tailwind basically only exists to trick people who are put off by long lists of classes into trying the framework.

You should almost never use it 😬

Reuse your utility-littered HTML instead.https://t.co/x6y4ksDwrt

— Adam Wathan (@adamwathan) February 9, 2020

Personally, I think Tailwind’s @apply feature is better than described.

Tailwind’s @apply is like Sass’s @includes

If you have been around during the time where Sass is the dominant CSS processing tool, you’ve probably heard of Sass mixins. They are blocks of code that you can make — in advance — to copy-paste into the rest of your code.

  • To create a mixin, you use @mixin
  • To use a mixin, you use @includes
// Defining the mixin @mixin some-mixin() { color: red; background: blue; } // Using the mixin .selector { @include some-mixin(); } /* Output */ .selector { color: red; background: blue; }

Tailwind’s @apply feature works the same way. You can define Tailwind utilities in advance and use them later in your code.

/* Defining the utility */ @utility some-utility { color: red; background: blue; } /* Applying the utility */ .selector { @apply some-utility; } /* Output */ .selector { color: red; background: blue; } Tailwind utilities are much better than Sass mixins

Tailwind’s utilities can be used directly in the HTML, so you don’t have to write a CSS rule for it to work.

@utility some-utility { color: red; background: blue; } <div class="some-utility">...</div>

On the contrary, for Sass mixins, you need to create an extra selector to house your @includes before using them in the HTML. That’s one extra step. Many of these extra steps add up to a lot.

@mixin some-mixin() { color: red; background: blue; } .selector { @include some-mixin(); } /* Output */ .selector { color: red; background: blue; } <div class="selector">...</div>

Tailwind’s utilities can also be used with their responsive variants. This unlocks media queries straight in the HTML and can be a superpower for creating responsive layouts.

<div class="utility1 md:utility2">…</div> A simple and practical example

One of my favorite — and most easily understood — examples of all time is a combination of two utilities that I’ve built for Splendid Layouts (a part of Splendid Labz):

  • vertical: makes a vertical layout
  • horizontal: makes a horizontal layout

Defining these two utilities is easy.

  • For vertical, we can use flexbox with flex-direction set to column.
  • For horizontal, we use flexbox with flex-direction set to row.
@utility horizontal { display: flex; flex-direction: row; gap: 1rem; } @utility vertical { display: flex; flex-direction: column; gap: 1rem; }

After defining these utilities, we can use them directly inside the HTML. So, if we want to create a vertical layout on mobile and a horizontal one on tablet or desktop, we can use the following classes:

<div class="vertical sm:horizontal">...</div>

For those who are new to Tailwind, sm: here is a breakpoint variant that tells Tailwind to activate a class when it goes beyond a certain breakpoint. By default, sm is set to 640px, so the above HTML produces a vertical layout on mobile, then switches to a horizontal layout at 640px.

Open Live Demo

If you prefer traditional CSS over composing classes like the example above, you can treat @apply like Sass @includes and use them directly in your CSS.

<div class="your-layout">...</div> .your-layout { @apply vertical; @media (width >= 640px) { @apply horizontal; } }

The beautiful part about both of these approaches is you can immediately see what’s happening with your layout — in plain English — without parsing code through a CSS lens. This means faster recognition and more maintainable code in the long run.

Tailwind’s utilities are a little less powerful compared to Sass mixins

Sass mixins are more powerful than Tailwind utilities because:

  1. They let you use multiple variables.
  2. They let you use other Sass features like @if and @for loops.
@mixin avatar($size, $circle: false) { width: $size; height: $size; @if $circle { border-radius: math.div($size, 2); } }

On the other hand, Tailwind utilities don’t have these powers. At the very maximum, Tailwind can let you take in one variable through their functional utilities.

/* Tailwind Functional Utility */ @utility tab-* { tab-size: --value(--tab-size-*); }

Fortunately, we’re not affected by this “lack of power” much because we can take advantage of all modern CSS improvements — including CSS variables. This gives you a ton of room to create very useful utilities.

Let’s go through another example

A second example I often like to showcase is the grid-simple utility that lets you create grids with CSS Grid easily.

We can declare a simple example here:

@utility grid-simple { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: var(--gap, 1rem); }

By doing this, we have effectively created a reusable CSS grid (and we no longer have to manually declare minmax everywhere).

After we have defined this utility, we can use Tailwind’s arbitrary properties to adjust the number of columns on the fly.

<div class="grid-simple [--cols:3]"> <div class="item">...</div> <div class="item">...</div> <div class="item">...</div> </div>

To make the grid responsive, we can add Tailwind’s responsive variants with arbitrary properties so we only set --cols:3 on a larger breakpoint.

<div class="grid-simple sm:[--cols:3]"> <div class="item">...</div> <div class="item">...</div> <div class="item">...</div> </div> Open Live Demo

This makes your layouts very declarative. You can immediately tell what’s going on when you read the HTML.

Now, on the other hand, if you’re uncomfortable with too much Tailwind magic, you can always use @apply to copy-paste the utility into your CSS. This way, you don’t have to bother writing repeat and minmax declarations every time you need a grid that grid-simple can create.

.your-layout { @apply grid-simple; @media (width >= 640px) { --cols: 3; } } <div class="your-layout"> ... </div>

By the way, using @apply this way is surprisingly useful for creating complex layouts! But that seems out of scope for this article so I’ll be happy to show you an example another day.

Wrapping up

Tailwind’s utilities are very powerful by themselves, but they’re even more powerful if you allow yourself to use @apply (and allow yourself to detach from traditional Tailwind advice). By doing this, you gain access to Tailwind as a tool instead of it being a dogmatic approach.

To make Tailwind’s utilities even more powerful, you might want to consider building utilities that can help you create layouts and nice visual effects quickly and easily.

I’ve built a handful of these utilities for Splendid Labz and I’m happy to share them with you if you’re interested! Just check out Splendid Layouts to see a subset of the utilities I’ve prepared.

By the way, the utilities I showed you above are watered-down versions of the actual ones I’m using in Splendid Labz.

One more note: When writing this, Splendid Layouts work with Tailwind 3, not Tailwind 4. I’m working on a release soon, so sign up for updates if you’re interested!

Tailwind’s @apply Feature is Better Than it Sounds originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Cascading Layouts: A Workshop on Resilient CSS Layouts

Css Tricks - Thu, 04/10/2025 - 1:26am

If I were starting with CSS today for the very first time, I would first want to spend time understanding writing modes because that’s a great place to wrap your head around direction and document flow. But right after that, and even more excitedly so, I would jump right into display and get a firm grasp on layout strategies.

And where would I learn that? There are lots of great resources out there. I mean, I have a full course called The Basics that gets into all that. I’d say you’d do yourself justice getting that from Andy Bell’s Complete CSS course as well.

But, hey, here’s a brand new way to bone up on layout: Miriam Suzanne is running a workshop later this month. Cascading Layouts is all about building more resilient and maintainable web layouts using modern CSS, without relying on third-party tools. Remember, Miriam works on CSS specifications, is a core contributor to Sass, and is just plain an all-around great educator. There are few, if any, who are more qualified to cover the ins and outs of CSS layout, and I can tell you that her work really helped inspire and inform the content in my course. The workshop is online, runs April 28-30, and is a whopping $ 100 off if you register by April 12.

Just a taste of what’s included:

Cascading Layouts: A Workshop on Resilient CSS Layouts originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Steven Heller’s Font of the Month: Homage Script

Typography - Wed, 04/09/2025 - 12:00pm

Read the book, Typographic Firsts

This month, Steven Heller takes a closer look at the Homage Script font family by Garage Fonts.

The post Steven Heller’s Font of the Month: Homage Script appeared first on I Love Typography.

CSS Carousels

Css Tricks - Wed, 04/09/2025 - 3:00am

The CSS Overflow Module Level 5 specification defines a couple of new features that are designed for creating carousel UI patterns:

  • Scroll Buttons: Buttons that the browser provides, as in literal <button> elements, that scroll the carousel content 85% of the area when clicked.
  • Scroll Markers: The little dots that act as anchored links, as in literal <a> elements that scroll to a specific carousel item when clicked.

Chrome has prototyped these features and released them in Chrome 135. Adam Argyle has a wonderful explainer over at the Chrome Developer blog. Kevin Powell has an equally wonderful video where he follows the explainer. This post is me taking notes from them.

First, some markup:

<ul class="carousel"> <li>...</li> <li>...</li> <li>...</li> <li>...</li> <li>...</li> </ul>

First, let’s set these up in a CSS auto grid that displays the list items in a single line:

.carousel { display: grid; grid-auto-flow: column; }

We can tailor this so that each list item takes up a specific amount of space, say 40%, and insert a gap between them:

.carousel { display: grid; grid-auto-flow: column; grid-auto-columns: 40%; gap: 2rem; }

This gives us a nice scrolling area to advance through the list items by moving left and right. We can use CSS Scroll Snapping to ensure that scrolling stops on each item in the center rather than scrolling right past them.

.carousel { display: grid; grid-auto-flow: column; grid-auto-columns: 40%; gap: 2rem; scroll-snap-type: x mandatory; > li { scroll-snap-align: center; } }

Kevin adds a little more flourish to the .carousel so that it is easier to see what’s going on. Specifically, he adds a border to the entire thing as well as padding for internal spacing.

So far, what we have is a super simple slider of sorts where we can either scroll through items horizontally or click the left and right arrows in the scroller.

We can add scroll buttons to the mix. We get two buttons, one to navigate one direction and one to navigate the other direction, which in this case is left and right, respectively. As you might expect, we get two new pseudo-elements for enabling and styling those buttons:

  • ::scroll-button(left)
  • ::scroll-button(right)

Interestingly enough, if you crack open DevTools and inspect the scroll buttons, they are actually exposed with logical terms instead, ::scroll-button(inline-start) and ::scroll-button(inline-end).

And both of those support the CSS content property, which we use to insert a label into the buttons. Let’s keep things simple and stick with “Left” and “Right” as our labels for now:

.carousel::scroll-button(left) { content: "Left"; } .carousel::scroll-button(right) { content: "Right"; }

Now we have two buttons above the carousel. Clicking them either advances the carousel left or right by 85%. Why 85%? I don’t know. And neither does Kevin. That’s just what it says in the specification. I’m sure there’s a good reason for it and we’ll get more light shed on it at some point.

But clicking the buttons in this specific example will advance the scroll only one list item at a time because we’ve set scroll snapping on it to stop at each item. So, even though the buttons want to advance by 85% of the scrolling area, we’re telling it to stop at each item.

Remember, this is only supported in Chrome at the time of writing:

CodePen Embed Fallback

We can select both buttons together in CSS, like this:

.carousel::scroll-button(left), .carousel::scroll-button(right) { /* Styles */ }

Or we can use the Universal Selector:

.carousel::scroll-button(*) { /* Styles */ }

And we can even use newer CSS Anchor Positioning to set the left button on the carousel’s left side and the right button on the carousel’s right side:

.carousel { /* ... */ anchor-name: --carousel; /* define the anchor */ } .carousel::scroll-button(*) { position: fixed; /* set containment on the target */ position-anchor: --carousel; /* set the anchor */ } .carousel::scroll-button(left) { content: "Left"; position-area: center left; } .carousel::scroll-button(right) { content: "Right"; position-area: center right; }

Notice what happens when navigating all the way to the left or right of the carousel. The buttons are disabled, indicating that you have reached the end of the scrolling area. Super neat! That’s something that is normally in JavaScript territory, but we’re getting it for free.

CodePen Embed Fallback

Let’s work on the scroll markers, or those little dots that sit below the carousel’s content. Each one is an <a> element anchored to a specific list item in the carousel so that, when clicked, you get scrolled directly to that item.

We get a new pseudo-element for the entire group of markers called ::scroll-marker-group that we can use to style and position the container. In this case, let’s set Flexbox on the group so that we can display them on a single line and place gaps between them in the center of the carousel’s inline size:

.carousel::scroll-marker-group { display: flex; justify-content: center; gap: 1rem; }

We also get a new scroll-marker-group property that lets us position the group either above (before) the carousel or below (after) it:

.carousel { /* ... */ scroll-marker-group: after; /* displayed below the content */ }

We can style the markers themselves with the new ::scroll-marker pseudo-element:

.carousel { /* ... */ > li::scroll-marker { content: ""; aspect-ratio: 1; border: 2px solid CanvasText; border-radius: 100%; width: 20px; } }

When clicking on a marker, it becomes the “active” item of the bunch, and we get to select and style it with the :target-current pseudo-class:

li::scroll-marker:target-current { background: CanvasText; }

Take a moment to click around the markers. Then take a moment using your keyboard and appreciate that we can all of the benefits of focus states as well as the ability to cycle through the carousel items when reaching the end of the markers. It’s amazing what we’re getting for free in terms of user experience and accessibility.

CodePen Embed Fallback

We can further style the markers when they are hovered or in focus:

li::scroll-marker:hover, li::scroll-marker:focus-visible { background: LinkText; }

And we can “animate” the scrolling effect by setting scroll-behavior: smooth on the scroll snapping. Adam smartly applies it when the user’s motion preferences allow it:

.carousel { /* ... */ @media (prefers-reduced-motion: no-preference) { scroll-behavior: smooth; } }

Buuuuut that seems to break scroll snapping a bit because the scroll buttons are attempting to slide things over by 85% of the scrolling space. Kevin had to fiddle with his grid-auto-columns sizing to get things just right, but showed how Adam’s example took a different sizing approach. It’s a matter of fussing with things to get them just right.

CodePen Embed Fallback

This is just a super early look at CSS Carousels. Remember that this is only supported in Chrome 135+ at the time I’m writing this, and it’s purely experimental. So, play around with it, get familiar with the concepts, and then be open-minded to changes in the future as the CSS Overflow Level 5 specification is updated and other browsers begin building support.

CSS Carousels originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Feeling Like I Have No Release: A Journey Towards Sane Deployments

Css Tricks - Mon, 04/07/2025 - 4:41am

When I was young and dinosaurs walked the earth, I worked on a software team that developed a web-based product for two years before ever releasing it. I don’t just mean we didn’t make it publicly available; we didn’t deploy it anywhere except for a test machine in the office, accessed by two internal testers, and this required a change to each tester’s hosts file. You don’t have to be an agile evangelist to spot the red flag. There’s “release early, release often,” which seemed revolutionary the first time I heard it after living under a waterfall for years, or there’s building so much while waiting so long to deploy that you guarantee weird surprises in a realistic deployment, let alone when you get feedback from real users. I’m told the first deployment experience to a web farm was very special.

A tale of a dodgy deployment

Being a junior, I was spared being involved in the first deployment. But towards the end of the first three-month cycle of fixes, the team leader asked me, “Would you be available on Tuesday at 2 a.m. to come to the office and do a deployment?”

“Yep, sure, no worries.” I went home thinking what a funny dude my team leader was.

So on Tuesday at 9 a.m., I show up and say good morning to the team leader and the architect, who sit together staring at one computer. I sit down at my dev machine and start typing.

“Man, what happened?” the team leader says over the partition. “You said you’d be here at 2 a.m.”

I look at him and see he is not smiling. I say, ”Oh. I thought you were joking.”

“I was not joking, and we have a massive problem with the deployment.”

Uh-oh.

I was junior and did not have the combined forty years of engineering experience of the team leader and architect, but what I had that they lacked was a well-rested brain, so I found the problem rather quickly: It was a code change the dev manager had made to the way we handled cookies, which didn’t show a problem on the internal test server but broke the world on the real web servers. Perhaps my finding the issue was the only thing that saved me from getting a stern lecture. By the time I left years later, it was just a funny story the dev manager shared in my farewell speech, along with nice compliments about what I had achieved for the company — I also accepted an offer to work for the company again later.

Breaking news: Human beings need sleep

I am sure the two seniors would have been capable of spotting the problem under different circumstances. They had a lot working against them: Sleep deprivation, together with the miscommunication about who would be present, would’ve contributed to feelings of panic, which the outage would’ve exacerbated after they powered through and deployed without me. More importantly, they didn’t know whether the problem was in the new code or human error in their manual deployment process of copying zipped binaries and website files to multiple servers, manually updating config files, comparing and updating database schemas — all in the wee hours of the morning.

They were sleepily searching for a needle in a haystack of their own making. The haystack wouldn’t have existed if they had a proven automated deployment process, and if they could be sure the problem could only reside in the code they deployed. There was no reason everything they were doing couldn’t be scripted. They could’ve woken up at 6 a.m. instead of 2 a.m. to verify the automated release of the website before shifting traffic to it and fix any problems that became evident in their release without disrupting real users. The company would get a more stable website and the expensive developers would have more time to focus on developing.

If you manually deploy overnight, and then drive, you’re a bloody idiot

The 2 a.m. deployments might seem funny if it wasn’t your night to attend and if you have a dark sense of humor. In the subsequent years, I attended many 2 a.m. deployments to atone for the one I slept through. The company paid for breakfast on those days, and if we proved the deployment was working, we could leave for the day and catch up on sleep, assuming we survived the drive home and didn’t end up sleeping forever.

The manual deployment checklist was perpetually incomplete and out-of-date, yet the process was never blamed for snafus on deployment days. In reality, sometimes it was a direct consequence of the fallibility of manually working from an inaccurate checklist. Sometimes manual deployment wasn’t directly the culprit, but it made pinpointing the problem or deciding whether to roll back unnecessarily challenging. And you knew rolling back would mean forgoing your sleep again the next day so you’d have a mounting sleep debt working against you.

I learned a lot from that team and the complex features I had the opportunity to build. But the deployment process was a step backward from my internship doing Windows programming because in that job I had to write installers so my code would work on user machines, which by nature of the task, I didn’t have access to. When web development removes that inherent limitation, it’s like a devil on your shoulder tempting you to do what seems easy in the moment and update production from your dev machine. You know you want to, especially when the full deployment process is hard and people want a fix straightaway. This is why if you automate deployments, you want to lock things down so that the automated process is the only way to deploy changes.

As I became more senior and had more say in how these processes happened at my workplace, I started researching — and I found it easy to relate to the shots taken at manual deployers, such as this presentation titled “Octopus Deploy and how to stop deploying like an idiot” and Octopus Deploy founder Paul Stovell’s sentiments on how to deploy database updates: “Your database isn’t in source control? You don’t deserve one. Go use Excel.” This approach to giving developers a kick in their complacency reminds me of the long-running anti-drunk driving ads here in Australia with the slogan “If you drink then drive, you’re a bloody idiot,” which scared people straight by insulting them for destructive life choices.

In the “Stop deploying like an idiot” talk, Damian Brady insults a hypothetical deployment manager at Acme Corp named Frank, who keeps being a hero by introducing risk and wasted time to a process that could be automated using Octopus, which would never make stupid mistakes like overwriting the config file.

“Frank’s pretty proud of his process in general,” says Damian. “Frank’s an idiot.”

Why are people like this?

Frankly, some of the Franks I have worked with were something worse than idiots. Comedian Jim Jeffries has a bit in which he says he’d take a nice idiot over a clever bastard. Frank’s a cunning bastard wolf in idiotic sheep’s clothing — the demographic of people who work in software shows above average IQ, and a person appointed “deployment manager” will have googled the options to make this task easier, but he chose not to use them. The thing is, Frank gets to seem important, make other devs look and feel stupid when they try to follow his process while he’s on leave, and even when he is working he gets to take overseas trips to hang with clients because he is the only one who can get the product working on a new server. Companies must be careful which behaviors they reward, and Conway’s law applies to deployment processes.

What I learned by being forced to do deployments manually

To an extent, the process reflecting hierarchy and division of responsibility is normal and necessary, which is why Octopus Deploy has built-in manual intervention and approval steps. But also, some of the motivations to stick with manual deployments are nonsense. Complex manual deployments are still more widespread than they need to be, which makes me feel bad for the developers who still live like me back in the 2000s — if you call that living.

I guess there is an argument for the team-building experiences in those 2 a.m. deployments, much like deployments in the military sense of the word may teach the troops some valuable life lessons, even if the purported reason for the battle isn’t the real reason, and the costs turn out to be higher than anyone expects.

It reminds me of a tour I had the good fortune to take in 2023 of the Adobe San Jose offices, in which a “Photoshop floor” includes time capsule conference rooms representing different periods in Photoshop’s history, including a 90’s room with a working Macintosh Classic running Photoshop 1.0. The past is an interesting and instructive place to visit but not somewhere you’d want to live in 2025.

Even so, my experience of Flintsones-style deployments gave me an appreciation for the ways a tool like Octopus Deploy automates everything I was forced to do manually in the past, which kept my motivation up when I was working through the teething problems once I was tasked with transforming a manual deployment process into an automated process. This appreciation for the value proposition of a tool like Octopus Deploy was why I later jumped at the opportunity to work for Octopus in 2021.

What I learned working for Octopus Deploy

The first thing I noticed was how friendly the devs were and the smoothness of the onboarding process, with only one small manual change to make the code run correctly in Docker on my dev box. The second thing I noticed was that this wasn’t heaven, and there were flaky integration tests, slow builds, and cake file output that hid the informative build errors. In fairness, at the time Octopus was in a period of learning how to upscale. There was a whole project I eventually joined to performance-tune the integration tests and Octopus itself. As an Octopus user, the product had seemed as close to magic as we were likely to find, compared to the hell we had to go through without a proper deployment tool. Yet there’s something heartening about knowing nobody has a flawless codebase, and even Octopus Deploy has some smelly code they have to deal with and suboptimal deployments of some stuff.

Once I made my peace with the fact that there’s no silver bullet that magically and perfectly solves any aspect of software, including deployments, my hot take is that deploying like an idiot comes down to a mismatch between the tools you use to deploy and the reward in complexity reduced versus complexity added. Therefore, one example of deploying like an idiot is the story I opened with, in which team members routinely drove to the office at 2 a.m. to manually deploy a complicated website involving database changes, background processes, web farms, and SLAs. But another example of deploying like an idiot might be a solo developer with a side project who sets up Azure Devops to push to Octopus Deploy and pays more than necessary in money and cognitive load. Indeed, Octopus is a deceptively complex tool that can automate anything, not only deployments, but the complexity comes at the price of a learning curve and the risk of decision fatigue.

For instance, when I used my “sharpening time” (the Octopus term for side-project time) to explore ways to deploy a JavaScript library, I found at least two different ways to do it in Octopus, depending on whether it’s acceptable to automate upgrading all your consumers to the latest version of your library or whether you need more control of versioning per consumer. Sidenote: the Angry Birds Octopus parody that Octopus marketing created to go with my “consumers of your JavaScript library as tenants” article was a highlight of my time at Octopus — I wish we could have made it playable like a Google Doodle.

Nowadays I see automation as a spectrum for how automatic and sophisticated you need things to be, somewhat separate from the choice of tools. The challenge is locating that sweet spot, where automation makes your life easier versus the cost of licensing fees and the time and energy you need to devote to working on the deployment process. Octopus Deploy might be at one end of the spectrum of automated deployments when you need lots of control over a complicated automatic process. On the other end of the spectrum, the guy who runs Can I Use found that adopting git-ftp was a life upgrade from manually copying the modified files to his web server while keeping his process simple and not spending a lot of energy on more sophisticated deployment systems. Somewhere in the middle reside things like Bitbucket Pipelines or GitHub Actions, which are more automated and sophisticated than just git-ftp from your dev machine, but less complicated than Octopus together with TeamCity, which could be overkill on a simple project.

The complexity of deployment might be something to consider when defining your architecture, similar to how planning poker can trigger a business to rethink the value of certain features once they obtain holistic feedback from the team on the overall cost. For instance, you might assume you need a database, but when you factor in the complexity it adds to roll-outs, you may be motivated to rethink whether your use case truly needs a database.

What about serverless? Does serverless solve our problems given it’s supposed to eliminate the need to worry about how the server works?

Reminder: Serverless isn’t serverless

It should be uncontroversial to say that “serverless” is a misnomer, but how much this inaccuracy matters is debatable. I’ll give this analogy for why I think the name “serverless” is a problem: Early cars had a right to call themselves “horseless carriages” because they were a paradigm shift that meant your carriage could move without a horse. “Driverless cars” shouldn’t be called that, because they don’t remove the need for a driver; it’s just that the driver is an AI. “Self-driving car” is therefore a better name. Self-driving cars often work well, but completely ignoring the limitations of how they work can be fatal. When you unpack the term “serverless,” it’s like a purportedly horseless carriage still pulled by horse — but the driver claims his feeding and handling of the horse will be managed so well, the carriage will be so insulated from neighing and horse flatulence, passengers will feel as if the horse doesn’t exist. My counterargument is that the reality of the horse is bound to affect the passenger experience sooner or later.

For example, one of my hobby projects was a rap battle chat deployed to Firebase. I needed the Firebase cloud function to calculate the score for each post using the same rhyme detection algorithm I used to power the front end. This worked fine in testing when I ran the Firebase function using the Cloud Functions emulator — but it performed unacceptably after my first deployment due to a cold start (loading the pronunciation dictionary was the likely culprit if you’re wondering). Much like my experiences in the 2000s, my code behaved dramatically differently on my dev machine than on the real Firebase, almost as though there is still a server I can’t pretend doesn’t exist — but now I had limited ability to tweak it. One way to fix it was to throw money at the problem.

That serverless experience reminds me of a scene in the science fiction novel Rainbows End in which the curmudgeonly main character cuts open a car that isn’t designed to be serviced, only to find that all the components inside are labeled “No user-serviceable parts within.” He’s assured that even if he could cut open those parts, the car is “Russian dolls all the way down.” One of the other characters asks him: “Who’d want to change them once they’re made? Just trash ’em if they’re not working like you want.”

I don’t want to seem like a curmudgeon — but my point is that while something like Firebase offers many conveniences and can simplify deployment and configuration, it can also move the problem to knowing which services are appropriate to pay extra for. And you may find your options are limited when things go wrong with a deployment or any other part of web development.

Deploying this article

Since I love self-referential twist endings, I’ll point out that even publishing an article like this has a variety of possible “deployment processes.” For instance, Octopus uses Jekyll for their blog. You make a branch with the markdown of your proposed blog post, and then marketing proposes changes before setting a publication date and merging. The relevant automated process will handle publication from there. This process has the advantage of using familiar tools for collaborating on changes to a file — but it might not feel approachable to teams not comfortable with Git, and it also might not be immediately apparent how to preview the final article as it will appear on the website.

On the other hand, when I create an article for CSS-Tricks, I use Dropbox Paper to create my initial draft, then send it to Geoff Graham, who makes edits, for which I get notifications. Once we have confirmed via email that we’re happy with the article, he manually ports it to Markdown in WordPress, then sends me a link to a pre-live version on the site to check before the article is scheduled for publication. It’s a manual process, so I sometimes find problems even in this “release” of static content collaborated by only two people — but you gotta weigh how much risk there is of mistakes against how much value there would be in fully automating the process. With anything you have to publish on the web, keep searching for that sweet spot of elegance, risk, and the reward-to-effort ratio.

Feeling Like I Have No Release: A Journey Towards Sane Deployments originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Usable Chat Interfaces to AI Models

LukeW - Sat, 04/05/2025 - 2:00pm

Seems like every app these days, including this Web site, has a chat interface. While giving powerful AI models an open-ended UI supports an enormous amount of use cases, these interfaces also come with issues. So here's some design approaches to address one of the most prominent ones.

First of all, I'm not against open-ended interfaces. While these kinds of UIs face the typical "blank slate" problem of what can or should I do here? They are an extremely flexible way to allow people to declare their intent (if they have one).

So what's the problem? In their article on Early Generative-AI User Behaviors, the Nielsen/Norman Group highlighted several usability issues in AI-chatbot interfaces. At the root of most was the observation that "people get lost when scrolling" streams of replies. Especially when AI models deliver lengthy outputs (as many are prone to do).

To account for these issues in the Ask LukeW feature on this site, where people ask relatively short questions and get long-form detailed answers, I made use of an expand and collapse pattern. You can see the difference between this approach and a more common chat UI pattern below.

Here's how this pattern looks in the Ask LukeW interface. The previous question and answer pairs are collapsed and therefore the same size, making it easier to focus on the content within and pick out relevant messages from the list when needed.

If you want to expand the content of an earlier question and answer pair, just tap on it to see its contents and the other messages collapse automatically.

We took this a step further in the interface for Bench, an AI-powered workspace for knowledge work. Unlike Ask LukeW, Bench has many tools it can use to help people get work done (search, data science, fact check, remember, etc.).

Each of these tools can create a lot of output. When they do, we place the results of each tool in a separate interface panel on the right. This panel is also editable so people can refine a tool's output manually when they just want to modify things a little bit.

When the next tool creates output or people start another task, that output shows up on the right. The tool that created the output, however, remains in the timeline on the left with link to what it produced. So you can quickly navigate to and open outputs.

But what happens when there's multiple outputs... don't we end up with the same problem of a long scrolling list to find what you need? To account for this, we (thanks Amelia) added a collapse timeline feature in Bench. Hovering over any reply reveals a little "condense this" icon on the timeline.

Selecting this icon will collapse the timeline down to just a list of tools with links to their output. This allows you to easily find what was produced for you in Bench and get back to it.

OK but even if the timeline is collapsed, people still have to scroll the timeline to find the things they need right? So they're still scrolling just less? For this reason, we also added a home page for each session in Bench.

If you close any output in the pane on the right, you see a title and summary of your session, all the files you used in it, and a list of all the outputs created in the session. This list can be sorted by the time the output was produced or by the tool that made the output. Selecting an output in this list opens it up. Selecting the tool that created it takes you to the point in the timeline where it was produced.

While I tried to illustrate this behavior with images, it's probably better experienced than read. So if you'd like to check out these interface solutions in Bench, here's an invite to the private preview.

A New “Web” Readiness Report

Css Tricks - Fri, 04/04/2025 - 3:05am

The beauty of research is finding yourself on a completely unrelated topic mere minutes from opening your browser. It happened to me while writing an Almanac entry on @namespace, an at-rule that we probably won’t ever use and is often regarded as a legacy piece of CSS. Maybe that’s why there wasn’t a lot of info about it until I found a 2010s post on @namespace by Divya Manian. The post was incredibly enlightening, but that’s beside the point; what’s important is that in Divya’s blog, there were arrows on the sides to read the previous and next posts:

Don’t ask me why, but without noticing, I somehow clicked the left arrow twice, which led me to a post on “Notes from HTML5 Readiness Hacking.”

What’s HTML 5 Readiness?!

HTML 5 Readiness was a site created by Paul Irish and Divya Manian that showed the browser support for several web features through the lens of a rainbow of colors. The features were considered (at the time) state-of-the-art or bleeding-edge stuff, such as media queries, transitions, video and audio tags, etc. As each browser supported a feature, a section of the rainbow would be added.

I think it worked from 2010 to 2013, although it showed browser support data from 2008. I can’t describe how nostalgic it made me feel; it reminded me of simpler times when even SVGs weren’t fully supported. What almost made me shed a tear was thinking that, if this tool was updated today, all of the features would be colored in a full rainbow.

A new web readiness

It got me thinking: there are so many new features coming to CSS (many that haven’t shipped to any browser) that there could be a new HTML5 Readiness with all of them. That’s why I set myself to do exactly that last weekend, a Web Readiness 2025 that holds each of the features coming to HTML and CSS I am most excited about.

You can visit it at webreadiness.com!

Right now, it looks kinda empty, but as time goes we will hopefully see how the rainbow grows:

Even though it was a weekend project, I took the opportunity to dip my toes into a couple of things I wanted to learn. Below are also some snippets I think are worth sharing.

The data is sourced from Baseline

My first thought was to mod the <baseline-status> web component made by the Chrome team because I have been wanting to use it since it came out. In short, it lets you embed the support data for a web feature directly into your blog. Not long ago, in fact, Geoff added it as a WordPress block in CSS-Tricks, which has been super useful while writing the Almanac:

However, I immediately realized that using the <baseline-status> would be needlessly laborious, so I instead pulled the data from the Web Features API — https://api.webstatus.dev/v1/features/ — and displayed it myself. You can find all the available features in the GitHub repo.

Each ray is a web component

Another feature I have been wanting to learn more about was Web Components, and since Geoff recently published his notes on Scott Jehl’s course Web Components Demystified, I thought it was the perfect chance. In this case, each ray would be a web component with a simple live cycle:

  1. Get instantiated.
  2. Read the feature ID from a data-feature attribute.
  3. Fetch its data from the Web Features API.
  4. Display its support as a list.

Said and done! The simplified version of that code looks something like the following:

class BaselineRay extends HTMLElement { constructor() { super(); } static get observedAttributes() { return ["data-feature"]; } attributeChangedCallback(property, oldValue, newValue) { if (oldValue !== newValue) { this[property] = newValue; } } async #fetchFeature(endpoint, featureID) { // Fetch Feature Function } async connectedCallback() { // Call fetchFeature and Output List } } customElements.define("baseline-ray", BaselineRay); Animations with the Web Animation API

I must admit, I am not too design-savvy (I hope it isn’t that obvious), so what I lacked in design, I made up with some animations. When the page initially loads, a welcome animation is easily achieved with a couple of timed keyframes. However, the animation between the rainbow and list layouts is a little more involved since it depends on the user’s input, so we have to trigger them with JavaScript.

At first, I thought it would be easier to do them with Same-Document View Transitions, but I found myself battling with the browser’s default transitions and the lack of good documentation beyond Chrome’s posts. That’s why I decided on the Web Animation API, which lets you trigger transitions in a declarative manner.

sibling-index() and sibling-count()

A while ago, I wrote about the sibling-index() and sibling-count() functions. As their names imply, they return the current index of an element among its sibling, and the total amount of siblings, respectively. While Chrome announced its intent to ship both functions, I know it will be a while until they reach baseline support, but I still needed them to rotate and move each ray.

In that same post, I talked about three options to polyfill each function. The first two were CSS-only, but this time I took the simplest JavaScript way which observes the number of rays and adds custom properties with its index and total count. Sure, it’s a bit overkill since the amount of rays doesn’t change, but pretty easy to implement:

const elements = document.querySelector(".rays"); const updateCustomProperties = () => { let index = 0; for (let element of elements.children) { element.style.setProperty("--sibling-index", index); index++; } elements.style.setProperty("--sibling-count", elements.children.length - 1); }; updateCustomProperties(); const observer = new MutationObserver(updateCustomProperties); const config = {attributes: false, childList: true, subtree: false}; observer.observe(elements, config);

With this, I could position each ray in a 180-degree range:

baseline-ray ul{ --position: calc(180 / var(--sibling-count) * var(--sibling-index) - 90); --rotation: calc(var(--position) * 1deg); transform: translateX(-50%) rotate(var(--rotation)) translateY(var(--ray-separation)); transform-origin: bottom center; } The selection is JavaScript-less

In the browser captions, if you hover over a specific browser, that browser’s color will pop out more in the rainbow while the rest becomes a little transparent. Since in my HTML, the caption element isn’t anyway near the rainbow (as a parent or a sibling), I thought I would need JavaScript for the task, but then I remembered I could simply use the :has() selector.

It works by detecting whenever the closest parent of both elements (it could be <section>, <main>, or the whole <body>) has a .caption item with a :hover pseudo-class. Once detected, we increase the size of each ray section of the same browser, while decreasing the opacity of the rest of the ray sections.

CodePen Embed Fallback What’s next?!

What’s left now is to wait! I hope people can visit the page from time to time and see how the rainbow grows. Like the original HTML 5 Readiness page, I also want to take a snapshot at the end of the year to see how it looks until each feature is fully supported. Hopefully, it won’t take long, especially seeing the browser’s effort to ship things faster and improve interoperability.

Also, let me know if you think a feature is missing! I tried my best to pick exciting features without baseline support.

View the report

A New “Web” Readiness Report originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

SMIL on?

Css Tricks - Wed, 04/02/2025 - 2:37am

I was chatting with Andy Clarke the other day about a new article he wants to write about SVG animations.

“I’ve read some things that said that SMIL might be a dead end.” He said. “Whaddya think?”

That was my impression, too. Sarah Drasner summed up the situation nicely way back in 2017:

Unfortunately, support for SMIL is waning in WebKit, and has never (nor will likely ever) exist for Microsoft’s IE or Edge browsers. 

Chrome was also in on the party and published an intent to deprecate SMIL, citing work in other browsers to support SVG animations in CSS. MDN linked to that same thread in its SMIL documentation when it published a deprecation warning.

Well, Chrome never deprecated SMIL. At least according to this reply in the thread dated 2023. And since then, we’ve also seen Microsoft’s Edge adopt a Chromium engine, effectively making it a Chrome clone. Also, last I checked, Caniuse reports full support in WebKit browsers.

This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.

DesktopChromeFirefoxIEEdgeSafari5411796Mobile / TabletAndroid ChromeAndroid FirefoxAndroidiOS Safari13413636.0-6.1

Now, I’m not saying that SMIL is perfectly alive and well. It could still very well be in the doldrums, especially when there are robust alternatives in CSS and JavaScript. But it’s also not dead in the water.

SMIL on? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Crafting Strong DX With Astro Components and TypeScript

Css Tricks - Tue, 04/01/2025 - 3:50am

I’m a big fan of Astro’s focus on developer experience (DX) and the onboarding of new developers. While the basic DX is strong, I can easily make a convoluted system that is hard to onboard my own developers to. I don’t want that to happen.

If I have multiple developers working on a project, I want them to know exactly what to expect from every component that they have at their disposal. This goes double for myself in the future when I’ve forgotten how to work with my own system!

To do that, a developer could go read each component and get a strong grasp of it before using one, but that feels like the onboarding would be incredibly slow. A better way would be to set up the interface so that as the developer is using the component, they have the right knowledge immediately available. Beyond that, it would bake in some defaults that don’t allow developers to make costly mistakes and alerts them to what those mistakes are before pushing code!

Enter, of course, TypeScript. Astro comes with TypeScript set up out of the box. You don’t have to use it, but since it’s there, let’s talk about how to use it to craft a stronger DX for our development teams.

Watch

I’ve also recorded a video version of this article that you can watch if that’s your jam. Check it out on YouTube for chapters and closed captioning.

Setup

In this demo, we’re going to use a basic Astro project. To get this started, run the following command in your terminal and choose the “Minimal” template.

npm create astro@latest

This will create a project with an index route and a very simple “Welcome” component. For clarity, I recommend removing the <Welcome /> component from the route to have a clean starting point for your project.

To add a bit of design, I’d recommend setting up Tailwind for Astro (though, you’re welcome to style your component however you would like including a style block in the component).

npx astro add tailwind

Once this is complete, you’re ready to write your first component.

Creating the basic Heading component

Let’s start by defining exactly what options we want to provide in our developer experience.

For this component, we want to let developers choose from any HTML heading level (H1-H6). We also want them to be able to choose a specific font size and font weight — it may seem obvious now, but we don’t want people choosing a specific heading level for the weight and font size, so we separate those concerns.

Finally, we want to make sure that any additional HTML attributes can be passed through to our component. There are few things worse than having a component and then not being able to do basic functionality later.

Using Dynamic tags to create the HTML element

Let’s start by creating a simple component that allows the user to dynamically choose the HTML element they want to use. Create a new component at ./src/components/Heading.astro.

--- // ./src/component/Heading.astro const { as } = Astro.props; const As = as; --- <As> <slot /> </As>

To use a prop as a dynamic element name, we need the variable to start with a capital letter. We can define this as part of our naming convention and make the developer always capitalize this prop in their use, but that feels inconsistent with how most naming works within props. Instead, let’s keep our focus on the DX, and take that burden on for ourselves.

In order to dynamically register an HTML element in our component, the variable must start with a capital letter. We can convert that in the frontmatter of our component. We then wrap all the children of our component in the <As> component by using Astro’s built-in <slot /> component.

Now, we can use this component in our index route and render any HTML element we want. Import the component at the top of the file, and then add <h1> and <h2> elements to the route.

--- // ./src/pages/index.astro import Layout from '../layouts/Layout.astro'; import Heading from '../components/Heading.astro'; --- <Layout> <Heading as="h1">Hello!</Heading> <Heading as="h2">Hello world</Heading> </Layout>

This will render them correctly on the page and is a great start.

Adding more custom props as a developer interface

Let’s clean up the element choosing by bringing it inline to our props destructuring, and then add in additional props for weight, size, and any additional HTML attributes.

To start, let’s bring the custom element selector into the destructuring of the Astro.props object. At the same time, let’s set a sensible default so that if a developer forgets to pass this prop, they still will get a heading.

--- // ./src/component/Heading.astro const { as: As="h2" } = Astro.props; --- <As> <slot /> </As>

Next, we’ll get weight and size. Here’s our next design choice for our component system: do we make our developers know the class names they need to use or do we provide a generic set of sizes and do the mapping ourselves? Since we’re building a system, I think it’s important to move away from class names and into a more declarative setup. This will also future-proof our system by allowing us to change out the underlying styling and class system without affecting the DX.

Not only do we future proof it, but we also are able to get around a limitation of Tailwind by doing this. Tailwind, as it turns out can’t handle dynamically-created class strings, so by mapping them, we solve an immediate issue as well.

In this case, our sizes will go from small (sm) to six times the size (6xl) and our weights will go from “light” to “bold”.

Let’s start by adjusting our frontmatter. We need to get these props off the Astro.props object and create a couple objects that we can use to map our interface to the proper class structure.

--- // ./src/component/Heading.astro const weights = { "bold": "font-bold", "semibold": "font-semibold", "medium": "font-medium", "light": "font-light" } const sizes= { "6xl": "text-6xl", "5xl": "text-5xl", "4xl": "text-4xl", "3xl": "text-3xl", "2xl": "text-2xl", "xl": "text-xl", "lg": "text-lg", "md": "text-md", "sm": "text-sm" } const { as: As="h2", weight="medium", size="2xl" } = Astro.props; ---

Depending on your use case, this amount of sizes and weights might be overkill. The great thing about crafting your own component system is that you get to choose and the only limitations are the ones you set for yourself.

From here, we can then set the classes on our component. While we could add them in a standard class attribute, I find using Astro’s built-in class:list directive to be the cleaner way to programmatically set classes in a component like this. The directive takes an array of classes that can be strings, arrays themselves, objects, or variables. In this case, we’ll select the correct size and weight from our map objects in the frontmatter.

--- // ./src/component/Heading.astro const weights = { bold: "font-bold", semibold: "font-semibold", medium: "font-medium", light: "font-light", }; const sizes = { "6xl": "text-6xl", "5xl": "text-5xl", "4xl": "text-4xl", "3xl": "text-3xl", "2xl": "text-2xl", xl: "text-xl", lg: "text-lg", md: "text-md", sm: "text-sm", }; const { as: As = "h2", weight = "medium", size = "2xl" } = Astro.props; --- <As class:list={[ sizes[size], weights[weight] ]} > <slot /> </As>

Your front-end should automatically shift a little in this update. Now your font weight will be slightly thicker and the classes should be applied in your developer tools.

From here, add the props to your index route, and find the right configuration for your app.

--- // ./src/pages/index.astro import Layout from '../layouts/Layout.astro'; import Heading from '../components/Heading.astro'; --- <Layout> <Heading as="h1" size="6xl" weight="light">Hello!</Heading> <Heading as="h3" size="xl" weight="bold">Hello world</Heading> </Layout>

Our custom props are finished, but currently, we can’t use any default HTML attributes, so let’s fix that.

Adding HTML attributes to the component

We don’t know what sorts of attributes our developers will want to add, so let’s make sure they can add any additional ones they need.

To do that, we can spread any other prop being passed to our component, and then add them to the rendered component.

--- // ./src/component/Heading.astro const weights = { // etc. }; const sizes = { // etc. }; const { as: As = "h2", weight = "medium", size = "md", ...attrs } = Astro.props; --- <As class:list={[ sizes[size], weights[weight] ]} {...attrs} > <slot /> </As>

From here, we can add any arbitrary attributes to our element.

--- // ./src/pages/index.astro import Layout from '../layouts/Layout.astro'; import Heading from '../components/Heading.astro'; --- <Layout> <Heading id="my-id" as="h1" size="6xl" weight="light">Hello!</Heading> <Heading class="text-blue-500" as="h3" size="xl" weight="bold">Hello world</Heading> </Layout>

I’d like to take a moment to truly appreciate one aspect of this code. Our <h1>, we add an id attribute. No big deal. Our <h3>, though, we’re adding an additional class. My original assumption when creating this was that this would conflict with the class:list set in our component. Astro takes that worry away. When the class is passed and added to the component, Astro knows to merge the class prop with the class:list directive and automatically makes it work. One less line of code!

In many ways, I like to consider these additional attributes as “escape hatches” in our component library. Sure, we want our developers to use our tools exactly as intended, but sometimes, it’s important to add new attributes or push our design system’s boundaries. For this, we allow them to add their own attributes, and it can create a powerful mix.

It looks done, but are we?

At this point, if you’re following along, it might feel like we’re done, but we have two issues with our code right now: (1) our component has “red squiggles” in our code editor and (2) our developers can make a BIG mistake if they choose.

The red squiggles come from type errors in our component. Astro gives us TypeScript and linting by default, and sizes and weights can’t be of type: any. Not a big deal, but concerning depending on your deployment settings.

The other issue is that our developers don’t have to choose a heading element for their heading. I’m all for escape hatches, but only if they don’t break the accessibility and SEO of my site.

Imagine, if a developer used this with a div instead of an h1 on the page. What would happen?We don’t have to imagine, make the change and see.

It looks identical, but now there’s no <h1> element on the page. Our semantic structure is broken, and that’s bad news for many reasons. Let’s use typing to help our developers make the best decisions and know what options are available for each prop.

Adding types to the component

To set up our types, first we want to make sure we handle any HTML attributes that come through. Astro, again, has our backs and has the typing we need to make this work. We can import the right HTML attribute types from Astro’s typing package. Import the type and then we can extend that type for our own props. In our example, we’ll select the h1 types, since that should cover most anything we need for our headings.

Inside the Props interface, we’ll also add our first custom type. We’ll specify that the as prop must be one of a set of strings, instead of just a basic string type. In this case, we want it to be h1–h6 and nothing else.

--- // ./src/component/Heading.astro import type { HTMLAttributes } from 'astro/types'; interface Props extends HTMLAttributes<'h1'> { as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; } //... The rest of the file ---

After adding this, you’ll note that in your index route, the <h1> component should now have a red underline for the as="div" property. When you hover over it, it will let you know that the as type does not allow for div and it will show you a list of acceptable strings.

If you delete the div, you should also now have the ability to see a list of what’s available as you try to add the string.

While it’s not a big deal for the element selection, knowing what’s available is a much bigger deal to the rest of the props, since those are much more custom.

Let’s extend the custom typing to show all the available options. We also denote these items as optional by using the ?:before defining the type.

While we could define each of these with the same type functionality as our as type, that doesn’t keep this future proofed. If we add a new size or weight, we’d have to make sure to update our type. To solve this, we can use a fun trick in TypeScript: keyof typeof.

There are two helper functions in TypeScript that will help us convert our weights and sizes object maps into string literal types:

  • typeof: This helper takes an object and converts it to a type. For instance typeof weights would return type { bold: string, semibold: string, ...etc}
  • keyof: This helper function takes a type and returns a list of string literals from that type’s keys. For instance keyof type { bold: string, semibold: string, ...etc} would return "bold" | "semibold" | ...etc which is exactly what we want for both weights and sizes.
--- // ./src/component/Heading.astro import type { HTMLAttributes } from 'astro/types'; interface Props extends HTMLAttributes<'h1'> { as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; weight?: keyof typeof weights; size?: keyof typeof sizes; } // ... The rest of the file

Now, when we want to add a size or weight, we get a dropdown list in our code editor showing exactly what’s available on the type. If something is selected, outside the list, it will show an error in the code editor helping the developer know what they missed.

While none of this is necessary in the creation of Astro components, the fact that it’s built in and there’s no additional tooling to set up means that using it is very easy to opt into.

I’m by no means a TypeScript expert, but getting this set up for each component takes only a few additional minutes and can save a lot of time for developers down the line (not to mention, it makes onboarding developers to your system much easier).

Crafting Strong DX With Astro Components and TypeScript originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Worlds Collide: Keyframe Collision Detection Using Style Queries

Css Tricks - Mon, 03/31/2025 - 4:59am

A friend DMs Lee Meyer a CodePen by Manuel Schaller containing a pure CSS simulation of one of the world’s earliest arcade games, Pong, with both paddles participating automatically, in an endless loop. The demo reminds Lee of an arcade machine in attract mode awaiting a coin, and the iconic imagery awakens muscle memory from his misspent childhood, causing him to search his pocket in which he finds the token a spooky shopkeeper gave him last year at the CSS tricks stall in the haunted carnival. The token gleams like a power-up in the light of his laptop, which has a slot he never noticed. He feeds the token into the slot, and the CodePen reloads itself. A vertical range input and a life counter appear, allowing him to control the left paddle and play the game in Chrome using a cocktail of modern and experimental CSS features to implement collision detection in CSS animations. He recalls the spooky shopkeeper’s warning that playing with these features has driven some developers to madness, but the shopkeeper’s voice in Lee’s head whispers: “Too late, we are already playing.”

CSS collision detection: Past and present

So, maybe the experience of using modern CSS to add collision detection and interactivity to an animation wasn’t as much like a screenplay sponsored by CSS as I depicted in the intro above — but it did feel like magic compared to what Alex Walker had to go through in 2013 to achieve a similar effect. Hilariously, he describes his implementation as “a glittering city of hacks built on the banks of the ol’ Hack River. On the Planet Hack.“ Alex’s version of CSS Pong cleverly combines checkbox hacks, sibling selectors, and :hover, whereas the CodePen below uses style queries to detect collisions. I feel it’s a nice illustration of how far CSS has come, and a testament to increased power and expressiveness of CSS more than a decade later. It shows how much power we get when combining new CSS features — in this case, that includes style queries, animatable custom properties, and animation timelines. The future CSS features of inline conditionals and custom functions might be able to simplify this code more.

CodePen Embed Fallback Collision detection with style queries

Interactive CSS animations with elements ricocheting off each other seems more plausible in 2025 and the code is somewhat sensible. While it’s unnecessary to implement Pong in CSS, and the CSS Working Group probably hasn’t been contemplating how to make that particular niche task easier, the increasing flexibility and power of CSS reinforce my suspicion that one day it will be a lifestyle choice whether to achieve any given effect with scripting or CSS.

The demo is a similar number of lines of CSS to Alex’s 2013 implementation, but it didn’t feel much like a hack. It’s a demo of modern CSS features working together in the way I expected after reading the instruction booklet. Sometimes when reading introductory articles about the new features we are getting in CSS, it’s hard to appreciate how game-changing they are till you see several features working together. As often happens when pushing the boundaries of a technology, we are going to bump up against the current limitations of style queries and animations. But it’s all in good fun, and we’ll learn about these CSS features in more detail than if we had not attempted this crazy experiment.

It does seem to work, and my 12-year-old and 7-year-old have both playtested it on my phone and laptop, so it gets the “works on Lee’s devices” seal of quality. Also, since Chrome now supports controlling animations using range inputs, we can make our game playable on mobile, unlike the 2013 version, which relied on :hover. Temani Afif provides a great explanation of how and why view progress timelines can be used to style anything based on the value of a range input.

Using style queries to detect if the paddle hit the ball

The ball follows a fixed path, and whether the player’s paddle intersects with the ball when it reaches our side is the only input we have into whether it continues its predetermined bouncy loop or the screen flashes red as the life counter goes down till we see the “Game Over” screen with the option to play again.

This type of interactivity is what game designers call a quick time event. It’s still a game for sure, but five months ago, when I was young and naive, I mused in my article on animation timelines that the animation timeline feature could open the door for advanced games and interactive experiences in CSS. I wrote that a video game is just a “hyper-interactive animation.” Indeed, the above experiment shows that the new features in CSS allow us to respond to user input in sophisticated ways, but the demo also clarifies the difference between the kind of interactivity we can expect from the current incarnation of CSS versus scripting. The above experiment is more like if Pong were a game inside the old-school arcade game Dragon’s Lair, which was one giant quick time event. It only works because there are limited possible outcomes, but they are certainly less limited than what we used to be able to achieve in CSS.

Since we know collision detection with the paddle is the only opportunity for the user to have a say in what happens next, let’s focus on that implementation. It will require more mental gymnastics than I would like, since container style queries only allow for name-value pairs with the same syntax as feature queries, meaning we can’t use “greater than” or “less than” operators when comparing numeric values like we do with container size queries which follow the same syntax as @media size queries.

The workaround below allows us to create style queries based on the ball position being in or out of the range of the paddle. If the ball hits our side, then by default, the play field will flash red and temporarily unpause the animation that decrements the life counter (more on that later). But if the ball hits our side and is within range of the paddle, we leave the life-decrementing animation paused, and make the field background green while the ball hits the paddle. Since we don’t have “greater than” or “less than” operators in style queries, we (ab)use the min() function. If the result equals the first argument then that argument is less than or equal to the second; otherwise it’s greater than the second argument. It’s logical but made me wish for better comparison operators in style queries. Nevertheless, I was impressed that style queries allow the collision detection to be fairly readable, if a little more verbose than I would like.

body { --int-ball-position-x: round(down, var(--ball-position-x)); --min-ball-position-y-and-top-of-paddle: min(var(--ball-position-y) + var(--ball-height), var(--ping-position)); --min-ball-position-y-and-bottom-of-paddle: min(var(--ball-position-y), var(--ping-position) + var(--paddle-height)); } @container style(--int-ball-position-x: var(--ball-left-boundary)) { .screen { --lives-decrement: running; .field { background: red; } } } @container style(--min-ball-position-y-and-top-of-paddle: var(--ping-position)) and style(--min-ball-position-y-and-bottom-of-paddle: var(--ball-position-y)) and style(--int-ball-position-x: var(--ball-left-boundary)) { .screen { --lives-decrement: paused; .field { background: green; } } } Responding to collisions

Now that we can style our playing field based on whether the paddle hits the ball, we want to decrement the life counter if our paddle misses the ball, and display “Game Over” when we run out of lives. One way to achieve side effects in CSS is by pausing and unpausing keyframe animations that run forwards. These days, we can style things based on custom properties, which we can set in animations. Using this fact, we can take the power of paused animations to another level.

body { animation: ball 8s infinite linear, lives 80ms forwards steps(4) var(--lives-decrement); --lives-decrement: paused; } .lives::after { content: var(--lives); } @keyframes lives { 0% { --lives: "3"; } 25% { --lives: "2"; } 75% { --lives: "1"; } 100% { --lives: "0"; } } @container style(--int-ball-position-x: var(--ball-left-boundary)) { .screen { --lives-decrement: running; .field { background: red; } } } @container style(--min-ball-position-y-and-top-of-paddle: var(--ping-position)) and style(--min-ball-position-y-and-bottom-of-paddle: var(--ball-position-y)) and style(--int-ball-position-x: 8) { .screen { --lives-decrement: paused; .field { background: green; } } } @container style(--lives: '0') { .field { display: none; } .game-over { display: flex; } }

So when the ball hits the wall and isn’t in range of the paddle, the lives-decrementing animation is unpaused long enough to let it complete one step. Once it reaches zero we hide the play field and display the “Game Over” screen. What’s fascinating about this part of the experiment is that it shows that, using style queries, all properties become indirectly possible to control via animations, even when working with non-animatable properties. And this applies to properties that control whether other animations play. This article touches on why play state deliberately isn’t animatable and could be dangerous to animate, but we know what we are doing, right?

Full disclosure: The play state approach did lead to hidden complexity in the choice of duration of the animations. I knew that if I chose too long a duration for the life-decrementing counter, it might not have time to proceed to the next step while the ball was hitting the wall, but if I chose too short a duration, missing the ball once might cause the player to lose more than one life.

I made educated guesses of suitable durations for the ball bouncing and life decrementing, and I expected that when working with fixed-duration predictable animations, the life counter would either always work or always fail. I didn’t expect that my first attempt at the implementation intermittently failed to decrement the life counter at the same point in the animation loop. Setting the durations of both these related animations to multiples of eight seems to fix the problem, but why would predetermined animations exhibit unpredictable behavior?

Forefeit the game before somebody else takes you out of the frame

I have theories as to why the unpredictability of the collision detection seemed to be fixed by setting the ball animation to eight seconds and the lives animation to 80 milliseconds. Again, pushing CSS to its limits forces us to think deeper about how it’s working.

  1. CSS appears to suffer from timer drift, meaning if you set a keyframes animation to last for one second, it will sometimes take slightly under or over one second. When there is a different rate of change between the ball-bouncing and life-losing, it would make sense that the potential discrepancy between the two would be pronounced and lead to unpredictable collision detection. When the rate of change in both animations is the same, they would suffer about equally from timer drift, meaning the frames still synchronize predictably. Or at least I’m hoping the chance they don’t becomes negligible.
  2. Alex’s 2013 version of Pong uses translate3d() to move the ball even though it only moves in 2D. Alex recommends this whenever possible “for efficient animation rendering, offloading processing to the GPU for smoother visual effects.” Doing this may have been an alternative fix if it leads to more precise animation timing. There are tradeoffs so I wasn’t willing to go down that rabbit hole of trying to tune the animation performance in this article — but it could be an interesting focus for future research into CSS collision detection.
  3. Maybe style queries take a varying amount of time to kick in, leading to some form of a race condition. It is possible that making the ball-bouncing animation slower made this problem less likely.
  4. Maybe the bug remains lurking in the shadows somewhere. What did I expect from a hack I achieved using a magic token from a spooky shopkeeper? Haven’t I seen any eighties movie ever?
Outro

You finish reading the article, and feel sure that the author’s rationale for his supposed fix for the bug is hogwash. Clearly, Lee has been driven insane by the allure of overpowering new CSS features, whereas you respect the power of CSS, but you also respect its limitations. You sit down to spend a few minutes with the collision detection CodePen to prove it is still broken, but then find other flaws in the collision detection, and you commence work on a fork that will be superior. Hey, speaking of timer drift, how is it suddenly 1 a.m.? Only a crazy person would stay up that late playing with CSS when they have to work the next day. “Madness,” repeats the spooky shopkeeper inside your head, and his laughter echoes somewhere in the night.

Roll the credits

This looping Pong CSS animation by Manuel Schaller gave me an amazing basis for adding the collision detection. His twitching paddle animations help give the illusion of playing against a computer opponent, so forking his CodePen let me focus on implementing the collision detection rather than reinventing Pong.

This author is grateful to the junior testing team, comprised of his seven-year-old and twelve-year-old, who declared the CSS Pong implementation “pretty cool.” They also suggested the green and red flashes to signal collisions and misses.

The intro and outro for this article were sponsored by the spooky shopkeeper who sells dangerous CSS tricks. He also sells frozen yoghurt, which he calls froghurt.

Worlds Collide: Keyframe Collision Detection Using Style Queries originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Ask LukeW: 2 Years and 27,000 Answers

LukeW - Fri, 03/28/2025 - 2:00pm

Time flies (insanely) fast during the AI tsunami all of us in the technology industry are facing. So it was surprising to learn my personal AI assistant, Ask LukeW, launched two years ago. Since then I've kept iterating on it when time allowed and two years later...

Ask LukeW is a feature I created for my website to answer people's questions about digital product design, startups, technology, and related topics. It's designed to provide personalized responses using my body of work in a scalable manner.

Since launching two years ago, people have asked (and the system has answered) over 27,000 questions. That averages out to more than 36 a day, which is definitely more than I'd be able to answer using my physical embodiment. So I've certainly gotten scale from the digital version of me.

Ask LukeW works by using AI to generate answers based on the thousands of text articles, hundreds of presentations, videos, and other content I've produced over the years. When you ask a question, AI models identify relevant concepts within my content and use them to create new answers. If the information comes from a specific article, audio file, or video, the source is cited, allowing you to explore the original material if you want to learn more.

In other words, instead of having to search through thousands of files on my website, you can simply ask questions in natural language and get tailored responses. Behind that simplicity is a lot of work on both the technology and design side. To unpack it all, I've written a series of articles on what that looks like and why. If you want to go deep into designing AI-powered experiences... have at it:

Automated Visual Regression Testing With Playwright

Css Tricks - Fri, 03/28/2025 - 5:04am

Comparing visual artifacts can be a powerful, if fickle, approach to automated testing. Playwright makes this seem simple for websites, but the details might take a little finessing.

Recent downtime prompted me to scratch an itch that had been plaguing me for a while: The style sheet of a website I maintain has grown just a little unwieldy as we’ve been adding code while exploring new features. Now that we have a better idea of the requirements, it’s time for internal CSS refactoring to pay down some of our technical debt, taking advantage of modern CSS features (like using CSS nesting for more obvious structure). More importantly, a cleaner foundation should make it easier to introduce that dark mode feature we’re sorely lacking so we can finally respect users’ preferred color scheme.

However, being of the apprehensive persuasion, I was reluctant to make large changes for fear of unwittingly introducing bugs. I needed something to guard against visual regressions while refactoring — except that means snapshot testing, which is notoriously slow and brittle.

In this context, snapshot testing means taking screenshots to establish a reliable baseline against which we can compare future results. As we’ll see, those artifacts are influenced by a multitude of factors that might not always be fully controllable (e.g. timing, variable hardware resources, or randomized content). We also have to maintain state between test runs, i.e. save those screenshots, which complicates the setup and means our test code alone doesn’t fully describe expectations.

Having procrastinated without a more agreeable solution revealing itself, I finally set out to create what I assumed would be a quick spike. After all, this wouldn’t be part of the regular test suite; just a one-off utility for this particular refactoring task.

Fortunately, I had vague recollections of past research and quickly rediscovered Playwright’s built-in visual comparison feature. Because I try to select dependencies carefully, I was glad to see that Playwright seems not to rely on many external packages.

Setup

The recommended setup with npm init playwright@latest does a decent job, but my minimalist taste had me set everything up from scratch instead. This do-it-yourself approach also helped me understand how the different pieces fit together.

Given that I expect snapshot testing to only be used on rare occasions, I wanted to isolate everything in a dedicated subdirectory, called test/visual; that will be our working directory from here on out. We’ll start with package.json to declare our dependencies, adding a few helper scripts (spoiler!) while we’re at it:

{ "scripts": { "test": "playwright test", "report": "playwright show-report", "update": "playwright test --update-snapshots", "reset": "rm -r ./playwright-report ./test-results ./viz.test.js-snapshots || true" }, "devDependencies": { "@playwright/test": "^1.49.1" } }

If you don’t want node_modules hidden in some subdirectory but also don’t want to burden the root project with this rarely-used dependency, you might resort to manually invoking npm install --no-save @playwright/test in the root directory when needed.

With that in place, npm install downloads Playwright. Afterwards, npx playwright install downloads a range of headless browsers. (We’ll use npm here, but you might prefer a different package manager and task runner.)

We define our test environment via playwright.config.js with about a dozen basic Playwright settings:

import { defineConfig, devices } from "@playwright/test"; let BROWSERS = ["Desktop Firefox", "Desktop Chrome", "Desktop Safari"]; let BASE_URL = "http://localhost:8000"; let SERVER = "cd ../../dist && python3 -m http.server"; let IS_CI = !!process.env.CI; export default defineConfig({ testDir: "./", fullyParallel: true, forbidOnly: IS_CI, retries: 2, workers: IS_CI ? 1 : undefined, reporter: "html", webServer: { command: SERVER, url: BASE_URL, reuseExistingServer: !IS_CI }, use: { baseURL: BASE_URL, trace: "on-first-retry" }, projects: BROWSERS.map(ua => ({ name: ua.toLowerCase().replaceAll(" ", "-"), use: { ...devices[ua] } })) });

Here we expect our static website to already reside within the root directory’s dist folder and to be served at localhost:8000 (see SERVER; I prefer Python there because it’s widely available). I’ve included multiple browsers for illustration purposes. Still, we might reduce that number to speed things up (thus our simple BROWSERS list, which we then map to Playwright’s more elaborate projects data structure). Similarly, continuous integration is YAGNI for my particular scenario, so that whole IS_CI dance could be discarded.

Capture and compare

Let’s turn to the actual tests, starting with a minimal sample.test.js file:

import { test, expect } from "@playwright/test"; test("home page", async ({ page }) => { await page.goto("/"); await expect(page).toHaveScreenshot(); });

npm test executes this little test suite (based on file-name conventions). The initial run always fails because it first needs to create baseline snapshots against which subsequent runs compare their results. Invoking npm test once more should report a passing test.

Changing our site, e.g. by recklessly messing with build artifacts in dist, should make the test fail again. Such failures will offer various options to compare expected and actual visuals:

We can also inspect those baseline snapshots directly: Playwright creates a folder for screenshots named after the test file (sample.test.js-snapshots in this case), with file names derived from the respective test’s title (e.g. home-page-desktop-firefox.png).

Generating tests

Getting back to our original motivation, what we want is a test for every page. Instead of arduously writing and maintaining repetitive tests, we’ll create a simple web crawler for our website and have tests generated automatically; one for each URL we’ve identified.

Playwright’s global setup enables us to perform preparatory work before test discovery begins: Determine those URLs and write them to a file. Afterward, we can dynamically generate our tests at runtime.

While there are other ways to pass data between the setup and test-discovery phases, having a file on disk makes it easy to modify the list of URLs before test runs (e.g. temporarily ignoring irrelevant pages).

Site map

The first step is to extend playwright.config.js by inserting globalSetup and exporting two of our configuration values:

export let BROWSERS = ["Desktop Firefox", "Desktop Chrome", "Desktop Safari"]; export let BASE_URL = "http://localhost:8000"; // etc. export default defineConfig({ // etc. globalSetup: require.resolve("./setup.js") });

Although we’re using ES modules here, we can still rely on CommonJS-specific APIs like require.resolve and __dirname. It appears there’s some Babel transpilation happening in the background, so what’s actually being executed is probably CommonJS? Such nuances sometimes confuse me because it isn’t always obvious what’s being executed where.

We can now reuse those exported values within a newly created setup.js, which spins up a headless browser to crawl our site (just because that’s easier here than using a separate HTML parser):

import { BASE_URL, BROWSERS } from "./playwright.config.js"; import { createSiteMap, readSiteMap } from "./sitemap.js"; import playwright from "@playwright/test"; export default async function globalSetup(config) { // only create site map if it doesn't already exist try { readSiteMap(); return; } catch(err) {} // launch browser and initiate crawler let browser = playwright.devices[BROWSERS[0]].defaultBrowserType; browser = await playwright[browser].launch(); let page = await browser.newPage(); await createSiteMap(BASE_URL, page); await browser.close(); }

This is fairly boring glue code; the actual crawling is happening within sitemap.js:

  • createSiteMap determines URLs and writes them to disk.
  • readSiteMap merely reads any previously created site map from disk. This will be our foundation for dynamically generating tests. (We’ll see later why this needs to be synchronous.)

Fortunately, the website in question provides a comprehensive index of all pages, so my crawler only needs to collect unique local URLs from that index page:

function extractLocalLinks(baseURL) { let urls = new Set(); let offset = baseURL.length; for(let { href } of document.links) { if(href.startsWith(baseURL)) { let path = href.slice(offset); urls.add(path); } } return Array.from(urls); }

Wrapping that in a more boring glue code gives us our sitemap.js:

import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; let ENTRY_POINT = "/topics"; let SITEMAP = join(__dirname, "./sitemap.json"); export async function createSiteMap(baseURL, page) { await page.goto(baseURL + ENTRY_POINT); let urls = await page.evaluate(extractLocalLinks, baseURL); let data = JSON.stringify(urls, null, 4); writeFileSync(SITEMAP, data, { encoding: "utf-8" }); } export function readSiteMap() { try { var data = readFileSync(SITEMAP, { encoding: "utf-8" }); } catch(err) { if(err.code === "ENOENT") { throw new Error("missing site map"); } throw err; } return JSON.parse(data); } function extractLocalLinks(baseURL) { // etc. }

The interesting bit here is that extractLocalLinks is evaluated within the browser context — thus we can rely on DOM APIs, notably document.links — while the rest is executed within the Playwright environment (i.e. Node).

Tests

Now that we have our list of URLs, we basically just need a test file with a simple loop to dynamically generate corresponding tests:

for(let url of readSiteMap()) { test(`page at ${url}`, async ({ page }) => { await page.goto(url); await expect(page).toHaveScreenshot(); }); }

This is why readSiteMap had to be synchronous above: Playwright doesn’t currently support top-level await within test files.

In practice, we’ll want better error reporting for when the site map doesn’t exist yet. Let’s call our actual test file viz.test.js:

import { readSiteMap } from "./sitemap.js"; import { test, expect } from "@playwright/test"; let sitemap = []; try { sitemap = readSiteMap(); } catch(err) { test("site map", ({ page }) => { throw new Error("missing site map"); }); } for(let url of sitemap) { test(`page at ${url}`, async ({ page }) => { await page.goto(url); await expect(page).toHaveScreenshot(); }); }

Getting here was a bit of a journey, but we’re pretty much done… unless we have to deal with reality, which typically takes a bit more tweaking.

Exceptions

Because visual testing is inherently flaky, we sometimes need to compensate via special casing. Playwright lets us inject custom CSS, which is often the easiest and most effective approach. Tweaking viz.test.js…

// etc. import { join } from "node:path"; let OPTIONS = { stylePath: join(__dirname, "./viz.tweaks.css") }; // etc. await expect(page).toHaveScreenshot(OPTIONS); // etc.

… allows us to define exceptions in viz.tweaks.css:

/* suppress state */ main a:visited { color: var(--color-link); } /* suppress randomness */ iframe[src$="/articles/signals-reactivity/demo.html"] { visibility: hidden; } /* suppress flakiness */ body:has(h1 a[href="/wip/unicode-symbols/"]) { main tbody > tr:last-child > td:first-child { font-size: 0; visibility: hidden; } }

:has() strikes again!

Page vs. viewport

At this point, everything seemed hunky-dory to me, until I realized that my tests didn’t actually fail after I had changed some styling. That’s not good! What I hadn’t taken into account is that .toHaveScreenshot only captures the viewport rather than the entire page. We can rectify that by further extending playwright.config.js.

export let WIDTH = 800; export let HEIGHT = WIDTH; // etc. projects: BROWSERS.map(ua => ({ name: ua.toLowerCase().replaceAll(" ", "-"), use: { ...devices[ua], viewport: { width: WIDTH, height: HEIGHT } } }))

…and then by adjusting viz.test.js‘s test-generating loop:

import { WIDTH, HEIGHT } from "./playwright.config.js"; // etc. for(let url of sitemap) { test(`page at ${url}`, async ({ page }) => { checkSnapshot(url, page); }); } async function checkSnapshot(url, page) { // determine page height with default viewport await page.setViewportSize({ width: WIDTH, height: HEIGHT }); await page.goto(url); await page.waitForLoadState("networkidle"); let height = await page.evaluate(getFullHeight); // resize viewport for before snapshotting await page.setViewportSize({ width: WIDTH, height: Math.ceil(height) }); await page.waitForLoadState("networkidle"); await expect(page).toHaveScreenshot(OPTIONS); } function getFullHeight() { return document.documentElement.getBoundingClientRect().height; }

Note that we’ve also introduced a waiting condition, holding until there’s no network traffic for a while in a crude attempt to account for stuff like lazy-loading images.

Be aware that capturing the entire page is more resource-intensive and doesn’t always work reliably: You might have to deal with layout shifts or run into timeouts for long or asset-heavy pages. In other words: This risks exacerbating flakiness.

Conclusion

So much for that quick spike. While it took more effort than expected (I believe that’s called “software development”), this might actually solve my original problem now (not a common feature of software these days). Of course, shaving this yak still leaves me itchy, as I have yet to do the actual work of scratching CSS without breaking anything. Then comes the real challenge: Retrofitting dark mode to an existing website. I just might need more downtime.

Automated Visual Regression Testing With Playwright originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Toward a Universal App Architecture

LukeW - Thu, 03/27/2025 - 2:00pm

In his AI Speaker Series presentation at Sutter Hill Ventures, Evan Bacon presented his work on ExpoRouter and DirectFlight, tools designed to address the challenges in mobile app development and distribution. Here's my notes from his talk:

  • 90% of time spent on mobile devices occurring inside native apps, particularly in regions outside America where mobile-first adoption is highest
  • Despite this, desktop and web platforms remain favored for high-performance tasks, though AI is rapidly enabling more productive mobile experiences for complex tasks like video editing and data analysis
  • While people are increasingly on mobile, getting native software int he app stores on these devices is still difficult for developers

ExpoRouter & Server Components
  • ExpoRouter is the first file-based framework that enables building both native apps and websites from a single codebase
  • By creating files in the app directory, developers automatically generate navigation systems that work across native and web environments
  • This leverages familiar web APIs like Link and Ahrefs for navigation, making the system intuitive for web developers
  • This combination of web-like development with native rendering has driven widespread adoption, with approximately one-third of content-driven apps in the iOS App Store (across shopping, business, sports, and food/drink categories) now using React Native and Expo
  • ExpoRouter's file-based architecture means every screen in an app automatically becomes linkable on both web and native platforms.
  • The system extends to advanced features like app clips, where URLs to websites can instantly open native content, downloading just what's needed on demand.
  • React Server Components represent the next evolution in this approach, enabling ExpoRouter apps to use the same data fetching and rendering strategies employed by best-in-class native applications
  • These components are serialized to a standardized React format that functions like HTML for any environment, creating a consistent system across platforms
  • The architecture supports streaming content delivery, allowing apps to start rendering on the client while the server continues creating elements and fetching data so apps look and feel identical to native apps
Deploying to App Stores
  • Even with improved development tools, getting apps to users remains complex, requiring Xcode (Mac-only), code signing, encryption status declarations, and a $100 developer fee
  • Expo addresses part of this challenge by enabling website deployment worldwide with a single command (EAS deploy), bringing modern web deployment practices to cross-platform development
  • But App Store distribution still requires Apple's multi-layered review processes
  • To address these distribution challenges, Bacon created DirectFlight, a tool that automates the process of adding testers to TestFlight
  • DirectFlight creates self-service links that allow users to add themselves to a development team and download apps without developer intervention for each user
  • This eliminates the need for developers to manually navigate Apple's slow interface, fill out redundant information, and manage the invitation process<.li>
  • DirectFlight works within Apple's rules by automating the official steps rather than circumventing them, making it a sustainable solution
  • There's lots of AI-powered tools for making native apps from text prompts. But these tools need streamlined distribution, which DirectFlight could help with.
  • As these tools mature, they promise to allow more developers to reach users directly with native experiences rather than being limited to web platforms

Vision Mission Strategy (Objectives)

LukeW - Wed, 03/26/2025 - 2:00pm

Across tech companies large and small there's often confusion around the difference between a vision, mission, and strategy. On the surface might feel like semantics but I've found thinking about the distinctions to be very helpful for aligning teams.

Basically I've pulled out these definitions enough times that it seemed like time to write them all out:

  • Vision is what the world looks like if you succeed. It paints a picture of the future state you're trying to achieve. It's an end state.
  • Mission is why your organization exists. It's the fundamental purpose that should guide all decisions and actions.
  • Strategy is how you can get there. It outlines the high-level approach you'll take to realize your vision and fulfill your mission.

So why is this confusing? For starters, having a purpose doesn't provide clarity on what the end state looks like. So a mission isn't really a substitute for a vision. To get to that end state you need a plan, that's what strategy is for. It's high level but not as much as mission and vision.

It might also help to go one step deeper and think about concrete objectives. The specific, measurable goals that support your strategy. They break down the bigger plan into actionable steps. This is where people get into acronyms like VMSO (Vision, Mission, Strategy, Objectives). Three concepts already enough, so let's not get too corporate-y here. (I'm probably already walking the line too much with this article.)

Instead I'll reference the poster Startup Vitamins made from one of my quotes: "Dream in Years, Plan in Months, Ship in Days." In the days of AI, it can feel like planning in months is too long but the higher level concept still holds up. Your dreams are the vision. Your plan is the strategy. Your set objectives and ship regularly to keep things moving toward the vision. Why do all this? cause of your mission. It's why you're there after all.

Support Logical Shorthands in CSS

Css Tricks - Mon, 03/24/2025 - 4:06am

There’s a bit of a blind spot when working with CSS logical properties concerning shorthands. Miriam explains:

Logical properties are a great way to optimize our sites in advance, without any real effort.

But what if we want to set multiple properties at once? This is where shorthands like margin and padding become useful. But they are currently limited to setting physical dimension. Logical properties are great, but they still feel like a second-class feature of the language.

There are a few 2-value shorthands that have been implemented, like margin-block for setting both the -block-start and -block-endmargins. I find those extremely useful and concise. But the existing 4-value shorthands feel stuck in the past. It’s surprising that a size shorthand can’t set the inline-size, and the inset shorthand doesn’t include inset-block-start. Is there any way to update those shorthand properties so that they can be used to set logical dimensions?

She ends with the money question, whether we can do anything about it. We’re currently in a position of having to choose between supporting flow-relative terms like block-start and inline-start with longhand properties and the ergonomic benefits of writing shorthand properties that are evaluated as physical terms like top, bottom, left, and right. Those of us writing CSS for a while likely have the muscle memory to adapt accordingly, but it’s otherwise a decision that has real consequences, particularly for multi-lingual sites.

Note that Miriam says this is something the CSS Working Group has been working on since 2017. And there’s a little momentum to pick it up and do something about it. The first thing you can do is support Miriam’s work — everything she does with the CSS Working Group (and it’s a lot) is a labor of love and relies on sponsorships, so chipping in is one way to push things forward.

The other thing you can do is chime into Miriam’s proposal that she published in 2021. I think it’s a solid idea. We can’t simply switch from physical to flow-relative terms in shorthand properties without triggering compatibility issues, so having some sort of higher-level instruction for CSS at the top of the stylesheet, perhaps as an at-rule that specifies which “mode” we’re in.

<coordinate-mode> = [ logical | physical ] or [ relative | absolute ] or ... @mode <coordinate-mode>; /* must come after @import and before any style rules */ @mode <coordinate-mode> { <stylesheet> } selector { property: value !<coordinate-mode>; }

Perhaps naming aside, it seems pretty reasonable, eh?

Support Logical Shorthands in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Revisiting CSS border-image

Css Tricks - Fri, 03/21/2025 - 3:24am

In my last article on “Revisiting CSS Multi-Column Layout”, I mentioned that almost twenty years have flown by since I wrote my first book, Transcending CSS. In it, I explained how and why to use what were, at the time, an emerging CSS property.

Ten years later, I wrote the Hardboiled Web Design Fifth Anniversary Edition, covering similar ground and introducing the new CSS border-image property.

Hint: I published an updated version, Transcending CSS Revisited which is free to read online. Hardboiled Web Design is available from my bookshop.

I was very excited about the possibilities this new property would offer. After all, we could now add images to the borders of any element, even table cells and rows (unless their borders had been set to collapse).

Since then, I’ve used border-image regularly. Yet, it remains one of the most underused CSS tools, and I can’t, for the life of me, figure out why. Is it possible that people steer clear of border-image because its syntax is awkward and unintuitive? Perhaps it’s because most explanations don’t solve the type of creative implementation problems that most people need to solve. Most likely, it’s both.

I’ve recently been working on a new website for Emmy-award-winning game composer Mike Worth. He hired me to create a highly graphical design that showcases his work, and I used border-image throughout.

Design by Andy Clarke, Stuff & Nonsense. Mike Worth’s website will launch in April 2025, but you can see examples from this article on CodePen. A brief overview of properties and values

First, here’s a short refresher. Most border-image explanations begin with this highly illuminating code snippet:

border-image: \[source\] [slice]/\[width]/[outset\] [repeat]

This is shorthand for a set of border-image properties, but it’s best to deal with properties individually to grasp the concept more easily.

A border-image’s source

I’ll start with the source of the bitmap or vector format image or CSS gradient to be inserted into the border space:

border-image-source: url('/img/scroll.png');

When I insert SVG images into a border, I have several choices as to how. I could use an external SVG file:

border-image-source: url('/img/scroll.svg');

Or I might convert my SVG to data URI using a tool like Base64.Guru although, as both SVG and HTML are XML-based, this isn’t recommended:

border-image-source: url('data:image/svg+xml;base64,…');

Instead, I can add the SVG code directly into the source URL value and save one unnecessary HTTP request:

border-image-source: url('data:image/svg+xml;utf8,…');

Finally, I could insert an entirely CSS-generated conical, linear, or radial gradient into my border:

border-image-source: conical-gradient(…);

Tip: It’s useful to remember that a browser renders a border-image above an element’s background and box-shadow but below its content. More on that a little later.

Slicing up a border-image

Now that I’ve specified the source of a border image, I can apply it to a border by slicing it up and using the parts in different positions around an element. This can be the most baffling aspect for people new to border-image.

Most border-image explanations show an example where the pieces will simply be equally-sized, like this:

However, a border-image can be developed from any shape, no matter how complex or irregular.

Instead of simply inserting an image into a border and watching it repeat around an element, invisible cut-lines slice up a border-image into nine parts. These lines are similar to the slice guides found in graphics applications. The pieces are, in turn, inserted into the nine regions of an element’s border.

The border-image-slice property defines the size of each slice by specifying the distance from each edge of the image. I could use the same distance from every edge:

border-image-slice: 65

I can combine top/bottom and left/right values:

border-image-slice: 115 65;

Or, I can specify distance values for all four cut-lines, running clockwise: top, right, bottom, left:

border-image-slice: 65 65 115 125;

The top-left of an image will be used on the top-left corner of an element’s border. The bottom-right will be used on the bottom-right, and so on.

I don’t need to add units to border-image-slice values when using a bitmap image as the browser correctly assumes bitmaps use pixels. The SVG viewBox makes using them a little different, so I also prefer to specify their height and width:

<svg height="600px" width="600px">…</svg>

Don’t forget to set the widths of these borders, as without them, there will be nowhere for a border’s image to display:

border-image-width: 65px 65px 115px 125px; Filling in the center

So far, I’ve used all four corners and sides of my image, but what about the center? By default, the browser will ignore the center of an image after it’s been sliced. But I can put it to use by adding the fill keyword to my border-image-slice value:

border-image-slice: 65px 65px 115px 125px fill; Setting up repeats

With the corners of my border images in place, I can turn my attention to the edges between them. As you might imagine, the slice at the top of an image will be placed on the top edge. The same is true of the right, bottom, and left edges. In a flexible design, we never know how wide or tall these edges will be, so I can fine-tune how images will repeat or stretch when they fill an edge.

Stretch: When a sliced image is flat or smooth, it can stretch to fill any height or width. Even a tiny 65px slice can stretch to hundreds or thousands of pixels without degrading.

border-image-repeat: stretch;

Repeat: If an image has texture, stretching it isn’t an option, so it can repeat to fill any height or width.

border-image-repeat: repeat;

Round: If an image has a pattern or shape that can’t be stretched and I need to match the edges of the repeat, I can specify that the repeat be round. A browser will resize the image so that only whole pieces display inside an edge.

border-image-repeat: round;

Space: Similar to round, when using the space property, only whole pieces will display inside an edge. But instead of resizing the image, a browser will add spaces into the repeat.

border-image-repeat: space;

When I need to specify a separate stretch, repeat, round, or space value for each edge, I can use multiple keywords:

border-image-repeat: stretch round; Outsetting a border-image

There can be times when I need an image to extend beyond an element’s border-box. Using the border-image-outset property, I can do just that. The simplest syntax extends the border image evenly on all sides by 10px:

border-image-outset: 10px;

Of course, there being four borders on every element, I could also specify each outset individually:

border-image-outset: 20px 10px; /* or */ border-image-outset: 20px 10px 0; border-image in action

Mike Worth is a video game composer who’s won an Emmy for his work. He loves ’90s animation — especially Disney’s Duck Tales — and he asked me to create custom artwork and develop a bold, retro-style design.

My challenge when developing for Mike was implementing my highly graphical design without compromising performance, especially on mobile devices. While it’s normal in CSS to accomplish the same goal in several ways, here, border-image often proved to be the most efficient.

Decorative buttons

The easiest and most obvious place to start was creating buttons reminiscent of stone tablets with chipped and uneven edges.

I created an SVG of the tablet shape and added it to my buttons using border-image:

button { border-image-repeat: stretch; border-image-slice: 10 10 10 10 fill; border-image-source: url('data:image/svg+xml;utf8,…'); border-image-width: 20px; }

I set the border-image-repeat on all edges to stretch and the center slice to fill so these stone tablet-style buttons expand along with their content to any height or width.

CodePen Embed Fallback Article scroll

I want every aspect of Mike’s website design to express his brand. That means continuing the ’90s cartoon theme in his long-form content by turning it into a paper scroll.

The markup is straightforward with just a single article element:

<article> <!-- ... --> </article>

But, I struggled to decide how to implement the paper effect. My first thought was to divide my scroll into three separate SVG files (top, middle, and bottom) and use pseudo-elements to add the rolled up top and bottom parts of the scroll. I started by applying a vertically repeating graphic to the middle of my article:

article { padding: 10rem 8rem; box-sizing: border-box; /* Scroll middle */ background-image: url('data:image/svg+xml;utf8,…'); background-position: center; background-repeat: repeat-y; background-size: contain; }

Then, I added two pseudo-elements, each containing its own SVG content:

article:before { display: block; position: relative; top: -30px; /* Scroll top */ content: url('data:image/svg+xml;utf8,…'); } article:after { display: block; position: relative; top: 50px; /* Scroll bottom */ content: url('data:image/svg+xml;utf8,…'); }

While this implementation worked as expected, using two pseudo-elements and three separate SVG files felt clumsy. However, using border-image, one SVG, and no pseudo-elements feels more elegant and significantly reduces the amount of code needed to implement the effect.

I started by creating an SVG of the complete tablet shape:

And I worked out the position of the four cut-lines:

Then, I inserted this single SVG into my article’s border by first selecting the source, slicing the image, and setting the top and bottom edges to stretch and the left and right edges to round:

article { border-image-slice: 150 95 150 95 fill; border-image-width: 150px 95px 150px 95px; border-image-repeat: stretch round; border-image-source: url('data:image/svg+xml;utf8,…'); }

The result is a flexible paper scroll effect which adapts to both the viewport width and any amount or type of content.

CodePen Embed Fallback Home page overlay

My final challenge was implementing the action-packed graphic I’d designed for Mike Worth’s home page. This contains a foreground SVG featuring Mike’s orangutan mascot and a zooming background graphic:

<section> <!-- content --> <div>...</div> <!-- ape --> <div> <svg>…</svg> </div> </section>

I defined the section as a positioning context for its children:

section { position: relative; }

Then, I absolutely positioned a pseudo-element and added the zooming graphic to its background:

section:before { content: ""; position: absolute; z-index: -1; background-image: url('data:image/svg+xml;utf8,…'); background-position: center center; background-repeat: no-repeat; background-size: 100%; }

I wanted this graphic to spin and add subtle movement to the panel, so I applied a simple CSS animation to the pseudo-element:

@keyframes spin-bg { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } section:before { animation: spin-bg 240s linear infinite; }

Next, I added a CSS mask to fade the edges of the zooming graphic into the background. The CSS mask-image property specifies a mask layer image, which can be a PNG image, an SVG image or mask, or a CSS gradient:

section:before { mask-image: radial-gradient(circle, rgb(0 0 0) 0%, rgb(0 0 0 / 0) 60%); mask-repeat: no-repeat; }

At this point, you might wonder where a border image could be used in this design. To add more interactivity to the graphic, I wanted to reduce its opacity and change its color — by adding a colored gradient overlay — when someone interacts with it. One of the simplest, but rarely-used, methods for applying an overlay to an element is using border-image. First, I added a default opacity and added a brief transition:

section:before { opacity: 1; transition: opacity .25s ease-in-out; }

Then, on hover, I reduced the opacity to .5 and added a border-image:

section:hover::before { opacity: .5; border-image: fill 0 linear-gradient(rgba(0,0,255,.25),rgba(255,0,0,1)); }

You may ponder why I’ve not used the other border-image values I explained earlier, so I’ll dissect that declaration. First is the border-image-slice value, where zero pixels ensures that the eight corners and edges stay empty. The fill keyword ensures the middle section is filled with the linear gradient. Second, the border-image-source is a CSS linear gradient that blends blue into red. A browser renders this border-image above the background but behind the content.

CodePen Embed Fallback Conclusion: You should take a fresh look at border-image

The border-image property is a powerful, yet often overlooked, CSS tool that offers incredible flexibility. By slicing, repeating, and outsetting images, you can create intricate borders, decorative elements, and even dynamic overlays with minimal code.

In my work for Mike Worth’s website, border-image proved invaluable, improving performance while maintaining a highly graphical aesthetic. Whether used for buttons, interactive overlays, or larger graphic elements, border-image can create visually striking designs without relying on extra markup or multiple assets.

If you’ve yet to experiment with border-image, now’s the time to revisit its potential and add it to your design toolkit.

Hint: Mike Worth’s website will launch in April 2025, but you can see examples from this article on CodePen.

About Andy Clarke

Often referred to as one of the pioneers of web design, Andy Clarke has been instrumental in pushing the boundaries of web design and is known for his creative and visually stunning designs. His work has inspired countless designers to explore the full potential of product and website design.

Andy’s written several industry-leading books, including Transcending CSS, Hardboiled Web Design, and Art Direction for the Web. He’s also worked with businesses of all sizes and industries to achieve their goals through design.

Visit Andy’s studio, Stuff & Nonsense, and check out his Contract Killer, the popular web design contract template trusted by thousands of web designers and developers.

Revisiting CSS border-image originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Quick Reminder That :is() and :where() Are Basically the Same With One Key Difference

Css Tricks - Thu, 03/20/2025 - 3:51am

I’ve seen a handful of recent posts talking about the utility of the :is() relational pseudo-selector. No need to delve into the details other than to say it can help make compound selectors a lot more readable.

:is(section, article, aside, nav) :is(h1, h2, h3, h4, h5, h6) { color: #BADA55; } /* ... which would be the equivalent of: */ section h1, section h2, section h3, section h4, section h5, section h6, article h1, article h2, article h3, article h4, article h5, article h6, aside h1, aside h2, aside h3, aside h4, aside h5, aside h6, nav h1, nav h2, nav h3, nav h4, nav h5, nav h6 { color: #BADA55; }

There’s just one catch: the specificity. The selector’s specificity matches the most specific selector in the function’s arguments. That’s not a big deal when working with a relatively flat style structure containing mostly element and class selectors, but if you toss an ID in there, then that’s the specificity you’re stuck with.

/* Specificity: 0 0 1 */ :is(h1, h2, h3, h4, h5, h6) { color: #BADA55; } /* Specificity: 1 0 0 */ :is(h1, h2, h3, h4, h5, h6, #id) { color: #BADA55; }

That can be a neat thing! For example, you might want to intentionally toss a made-up ID in there to force a style the same way you might do with the !important keyword.

What if you don’t want that? Some articles suggest nesting selectors instead which is cool but not quite with the same nice writing ergonomics.

There’s where I want to point to the :where() selector instead! It’s the exact same thing as :is() but without the specificity baggage. It always carries a specificity score of zero. You might even think of it as a sort of specificity reset.

/* Specificity: 0 0 0 */ :where(h1, h2, h3, h4, h5, h6) { color: #BADA55; } /* Specificity: 0 0 0 */ :where(h1, h2, h3, h4, h5, h6, #id) { color: #BADA55; }

So, is there a certain selector hijacking your :is() specificity? You might want :where() instead.

Quick Reminder That :is() and :where() Are Basically the Same With One Key Difference originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Web Components Demystified

Css Tricks - Fri, 03/14/2025 - 2:51am

Scott Jehl released a course called Web Components Demystified. I love that name because it says what the course is about right on the tin: you’re going to learn about web components and clear up any confusion you may already have about them.

And there’s plenty of confusion to go around! “Components” is already a loaded term that’s come to mean everything from a piece of UI, like a search component, to an element you can drop in and reuse anywhere, such as a React component. The web is chock-full of components, tell you what.

But what we’re talking about here is a set of standards where HTML, CSS, and JavaScript rally together so that we can create custom elements that behave exactly how we want them to. It’s how we can make an element called <tasty-pizza> and the browser knows what to do with it.

This is my full set of notes from Scott’s course. I wouldn’t say they’re complete or even a direct one-to-one replacement for watching the course. You’ll still want to do that on your own, and I encourage you to because Scott is an excellent teacher who makes all of this stuff extremely accessible, even to noobs like me.

Chapter 1: What Web Components Are… and Aren’t

Web components are not built-in elements, even though that’s what they might look like at first glance. Rather, they are a set of technologies that allow us to instruct what the element is and how it behaves. Think of it the same way that “responsive web design” is not a thing but rather a set of strategies for adapting design to different web contexts. So, just as responsive web design is a set of ingredients — including media fluid grids, flexible images, and media queries — web components are a concoction involving:

Custom elements

These are HTML elements that are not built into the browser. We make them up. They include a letter and a dash.

<my-fancy-heading> Hey, I'm Fancy </my-fancy-heading>

We’ll go over these in greater detail in the next module.

HTML templates

Templates are bits of reusable markup that generate more markup. We can hide something until we make use of it.

<template> <li class="user"> <h2 class="name"></h2> <p class="bio"></p> </li> </template>

Much more on this in the third module.

Shadow DOM

The DOM is queryable.

document.querySelector("h1"); // <h1>Hello, World</h1>

The Shadow DOM is a fragment of the DOM where markup, scripts, and styles are encapsulated from other DOM elements. We’ll cover this in the fourth module, including how to <slot> content.

There used to be a fourth “ingredient” called HTML Imports, but those have been nixed.

In short, web components might be called “components” but they aren’t really components more than technologies. In React, components sort of work like partials. It defines a snippet of HTML that you drop into your code and it outputs in the DOM. Web Components are built off of HTML Elements. They are not replaced when rendered the way they are in JavaScript component frameworks. Web components are quite literally HTML elements and have to obey HTML rules. For example:

<!-- Nope --> <ul> <my-list-item></my-list-item> <!-- etc. --> </ul> <!-- Yep --> <ul> <li> <my-list-item></my-list-item> </li> </ul>

We’re generating meaningful HTML up-front rather than rendering it in the browser through the client after the fact. Provide the markup and enhance it! Web components have been around a while now, even if it seems we’re only starting to talk about them now.

Chapter 2: Custom Elements

First off, custom elements are not built-in HTML elements. We instruct what they are and how they behave. They are named with a dash and at must contain least one letter. All of the following are valid names for custom elements:

  • <super-component>
  • <a->
  • <a-4->
  • <card-10.0.1>
  • <card-♠️>

Just remember that there are some reserved names for MathML and SVG elements, like <font-face>. Also, they cannot be void elements, e.g. <my-element />, meaning they have to have a correspoonding closing tag.

Since custom elements are not built-in elements, they are undefined by default — and being undefined can be a useful thing! That means we can use them as containers with default properties. For example, they are display: inline by default and inherit the current font-family, which can be useful to pass down to the contents. We can also use them as styling hooks since they can be selected in CSS. Or maybe they can be used for accessibility hints. The bottom line is that they do not require JavaScript in order to make them immediately useful.

Working with JavaScript. If there is one <my-button> on the page, we can query it and set a click handler on it with an event listener. But if we were to insert more instances on the page later, we would need to query it when it’s appended and re-run the function since it is not part of the original document rendering.

Defining a custom element

This defines and registers the custom element. It teaches the browser that this is an instance of the Custom Elements API and extends the same class that makes other HTML elements valid HTML elements:

<my-element>My Element</my-element> <script> customElements.define("my-element", class extends HTMLElement {}); </script>

Check out the methods we get immediate access to:

Breaking down the syntax customElements .define( "my-element", class extends HTMLElement {} ); // Functionally the same as: class MyElement extends HTMLElement {} customElements.define("my-element", MyElement); export default myElement // ...which makes it importable by other elements: import MyElement from './MyElement.js'; const myElement = new MyElement(); document.body.appendChild(myElement); // <body> // <my-element></my-element> // </body> // Or simply pull it into a page // Don't need to `export default` but it doesn't hurt to leave it // <my-element>My Element</my-element> // <script type="module" src="my-element.js"></script>

It’s possible to define a custom element by extending a specific HTML element. The specification documents this, but Scott is focusing on the primary way.

class WordCount extends HTMLParagraphElement customElements.define("word-count", WordCount, { extends: "p" }); // <p is="word-count">This is a custom paragraph!</p>

Scott says do not use this because WebKit is not going to implement it. We would have to polyfill it forever, or as long as WebKit holds out. Consider it a dead end.

The lifecycle

A component has various moments in its “life” span:

  • Constructed (constructor)
  • Connected (connectedCallback)
  • Adopted (adoptedCallback)
  • Attribute Changed (attributeChangedCallback)
  • Disconnected (disconnectedCallback)

We can hook into these to define the element’s behavior.

class myElement extends HTMLElement { constructor() {} connectedCallback() {} adoptedCallback() {} attributeChangedCallback() {} disconnectedCallback() {} } customElements.define("my-element", MyElement); constructor() class myElement extends HTMLElement { constructor() { // provides us with the `this` keyword super() // add a property this.someProperty = "Some value goes here"; // add event listener this.addEventListener("click", () => {}); } } customElements.define("my-element", MyElement);

“When the constructor is called, do this…” We don’t have to have a constructor when working with custom elements, but if we do, then we need to call super() because we’re extending another class and we’ll get all of those properties.

Constructor is useful, but not for a lot of things. It’s useful for setting up initial state, registering default properties, adding event listeners, and even creating Shadow DOM (which Scott will get into in a later module). For example, we are unable to sniff out whether or not the custom element is in another element because we don’t know anything about its parent container yet (that’s where other lifecycle methods come into play) — we’ve merely defined it.

connectedCallback() class myElement extends HTMLElement { // the constructor is unnecessary in this example but doesn't hurt. constructor() { super() } // let me know when my element has been found on the page. connectedCallback() { console.log(`${this.nodeName} was added to the page.`); } } customElements.define("my-element", MyElement);

Note that there is some strangeness when it comes to timing things. Sometimes isConnected returns true during the constructor. connectedCallback() is our best way to know when the component is found on the page. This is the moment it is connected to the DOM. Use it to attach event listeners.

If the <script> tag comes before the DOM is parsed, then it might not recognize childNodes. This is not an uncommon situation. But if we add type="module" to the <script>, then the script is deferred and we get the child nodes. Using setTimeout can also work, but it looks a little gross.

disconnectedCallback class myElement extends HTMLElement { // let me know when my element has been found on the page. disconnectedCallback() { console.log(`${this.nodeName} was removed from the page.`); } } customElements.define("my-element", MyElement);

This is useful when the component needs to be cleaned up, perhaps like stopping an animation or preventing memory links.

adoptedCallback()

This is when the component is adopted by another document or page. Say you have some iframes on a page and move a custom element from the page into an iframe, then it would be adopted in that scenario. It would be created, then added, then removed, then adopted, then added again. That’s a full lifecycle! This callback is adopted automatically simply by picking it up and dragging it between documents in the DOM.

Custom elements and attributes

Unlike React, HTML attributes are strings (not props!). Global attributes work as you’d expect, though some global attributes are reflected as properties. You can make any attribute do that if you want, just be sure to use care and caution when naming because, well, we don’t want any conflicts.

Avoid standard attributes on a custom element as well, as that can be confusing particularly when handing a component to another developer. Example: using type as an attribute which is also used by <input> elements. We could say data-type instead. (Remember that Chris has a comprehensive guide on using data attributes.)

Examples

Here’s a quick example showing how to get a greeting attribute and set it on the custom element:

class MyElement extends HTMLElement { get greeting() { return this.getAttribute('greeting'); // return this.hasAttribute('greeting'); } set greeting(val) { if(val) { this.setAttribute('greeting', val); // this setAttribute('greeting', ''); } else { this.removeAttribute('greeting'); } } } customElements.define("my-element", MyElement);

Another example, this time showing a callback for when the attribute has changed, which prints it in the element’s contents:

<my-element greeting="hello">hello</my-element> <!-- Change text greeting when attribite greeting changes --> <script> class MyElement extends HTMLElement { static observedAttributes = ["greeting"]; attributeChangedCallback(name, oldValue, newValue) { if (name === 'greeting' && oldValue && oldValue !== newValue) { console.log(name + " changed"); this.textContent = newValue; } } } customElements.define("my-element", MyElement); </script>

A few more custom element methods:

customElements.get('my-element'); // returns MyElement Class customElements.getName(MyElement); // returns 'my-element' customElements.whenDefined("my-element"); // waits for custom element to be defined const el = document.createElement("spider-man"); class SpiderMan extends HTMLElement { constructor() { super(); console.log("constructor!!"); } } customElements.define("spider-man", SpiderMan); customElements.upgrade(el); // returns "constructor!!"

Custom methods and events:

<my-element><button>My Element</button></my-element> <script> customElements.define("my-element", class extends HTMLElement { connectedCallback() { const btn = this.firstElementChild; btn.addEventListener("click", this.handleClick) } handleClick() { console.log(this); } }); </script>

Bring your own base class, in the same way web components frameworks like Lit do:

class BaseElement extends HTMLElement { $ = this.querySelector; } // extend the base, use its helper class myElement extends BaseElement { firstLi = this.$("li"); } Practice prompt

Create a custom HTML element called <say-hi> that displays the text “Hi, World!” when added to the page:

CodePen Embed Fallback

Enhance the element to accept a name attribute, displaying "Hi, [Name]!" instead:

CodePen Embed Fallback Chapter 3: HTML Templates

The <template> element is not for users but developers. It is not exposed visibly by browsers.

<template>The browser ignores everything in here.</template>

Templates are designed to hold HTML fragments:

<template> <div class="user-profile"> <h2 class="name">Scott</h2> <p class="bio">Author</p> </div> </template>

A template is selectable in CSS; it just doesn’t render. It’s a document fragment. The inner document is a #document-fragment. Not sure why you’d do this, but it illustrates the point that templates are selectable:

template { display: block; }` /* Nope */ template + div { height: 100px; width: 100px; } /* Works */ The content property

No, not in CSS, but JavaScript. We can query the inner contents of a template and print them somewhere else.

<template> <p>Hi</p> </template> <script> const myTmpl = documenty.querySelector("template").content; console.log(myTmpl); </script> Using a Document Fragment without a <template> const myFrag = document.createDocumentFragment(); myFrag.innerHTML = "<p>Test</p>"; // Nope const myP = document.createElement("p"); // Yep myP.textContent = "Hi!"; myFrag.append(myP); // use the fragment document.body.append(myFrag); Clone a node <template> <p>Hi</p> </template> <script> const myTmpl = documenty.querySelector("template").content; console.log(myTmpl); // Oops, only works one time! We need to clone it. </script>

Oops, the component only works one time! We need to clone it if we want multiple instances:

<template> <p>Hi</p> </template> <script> const myTmpl = document.querySelector("template").content; document.body.append(myTmpl.cloneNode(true)); // true is necessary document.body.append(myTmpl.cloneNode(true)); document.body.append(myTmpl.cloneNode(true)); document.body.append(myTmpl.cloneNode(true)); </script> A more practical example

Let’s stub out a template for a list item and then insert them into an unordered list:

<template id="tmpl-user"><li><strong></strong>: <span></span></li></template> <ul id="users"></ul> <script> const usersElement = document.querySelector("#users"); const userTmpl = document.querySelector("#tmpl-user").content; const users = [{name: "Bob", title: "Artist"}, {name: "Jane", title: "Doctor"}]; users.forEach(user => { let thisLi = userTmpl.cloneNode(true); thisLi.querySelector("strong").textContent = user.name; thisLi.querySelector("span").textContent = user.title; usersElement.append(thisLi); }); </script>

The other way to use templates that we’ll get to in the next module: Shadow DOM

<template shadowroot=open> <p>Hi, I'm in the Shadow DOM</p> </template> Chapter 4: Shadow DOM

Here we go, this is a heady chapter! The Shadow DOM reminds me of playing bass in a band: it’s easy to understand but incredibly difficult to master. It’s easy to understand that there are these nodes in the DOM that are encapsulated from everything else. They’re there, we just can’t really touch them with regular CSS and JavaScript without some finagling. It’s the finagling that’s difficult to master. There are times when the Shadow DOM is going to be your best friend because it prevents outside styles and scripts from leaking in and mucking things up. Then again, you’re most certainly going go want to style or apply scripts to those nodes and you have to figure that part out.

That’s where web components really shine. We get the benefits of an element that’s encapsulated from outside noise but we’re left with the responsibility of defining everything for it ourselves.

Select elements are a great example of the Shadow DOM. Shadow roots! Slots! They’re all part of the puzzle. Using the Shadow DOM

We covered the <template> element in the last chapter and determined that it renders in the Shadow DOM without getting displayed on the page.

<template shadowrootmode="closed"> <p>This will render in the Shadow DOM.</p> </template>

In this case, the <template> is rendered as a #shadow-root without the <template> element’s tags. It’s a fragment of code. So, while the paragraph inside the template is rendered, the <template> itself is not. It effectively marks the Shadow DOM’s boundaries. If we were to omit the shadowrootmode attribute, then we simply get an unrendered template. Either way, though, the paragraph is there in the DOM and it is encapsulated from other styles and scripts on the page.

These are all of the elements that can have a shadow. Breaching the shadow

There are times you’re going to want to “pierce” the Shadow DOM to allow for some styling and scripts. The content is relatively protected but we can open the shadowrootmode and allow some access.

<div> <template shadowrootmode="open"> <p>This will render in the Shadow DOM.</p> </template> </div>

Now we can query the div that contains the <template> and select the #shadow-root:

document.querySelector("div").shadowRoot // #shadow-root (open) // <p>This will render in the Shadow DOM.</p>

We need that <div> in there so we have something to query in the DOM to get to the paragraph. Remember, the <template> is not actually rendered at all.

Additional shadow attributes <!-- should this root stay with a parent clone? --> <template shadowrootcloneable> <!-- allow shadow to be serialized into a string object — can forget about this --> <template shadowrootserializable> <!-- click in element focuses first focusable element --> <template shadowrootdelegatesfocus> Shadow DOM siblings

When you add a shadow root, it becomes the only rendered root in that shadow host. Any elements after a shadow root node in the DOM simply don’t render. If a DOM element contains more than one shadow root node, the ones after the first just become template tags. It’s sort of like the Shadow DOM is a monster that eats the siblings.

Slots bring those siblings back!

<div> <template shadowroot="closed"> <slot></slot> <p>I'm a sibling of a shadow root, and I am visible.</p> </template> </div>

All of the siblings go through the slots and are distributed that way. It’s sort of like slots allow us to open the monster’s mouth and see what’s inside.

Declaring the Shadow DOM

Using templates is the declarative way to define the Shadow DOM. We can also define the Shadow DOM imperatively using JavaScript. So, this is doing the exact same thing as the last code snippet, only it’s done programmatically in JavaScript:

<my-element> <template shadowroot="open"> <p>This will render in the Shadow DOM.</p> </template> </my-element> <script> customElements.define('my-element', class extends HTMLElement { constructor() { super(); // attaches a shadow root node this.attachShadow({mode: "open"}); // inserts a slot into the template this.shadowRoot.innerHTML = '<slot></slot>'; } }); </script>

Another example:

<my-status>available</my-status> <script> customElements.define('my-status', class extends HTMLElement { constructor() { super(); this.attachShadow({mode: "open"}); this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>'; } }); </script>

So, is it better to be declarative or imperative? Like the weather where I live, it just depends.

Both approaches have their benefits.

We can set the shadow mode via Javascript as well:

// open this.attachShadow({mode: open}); // closed this.attachShadow({mode: closed}); // cloneable this.attachShadow({cloneable: true}); // delegateFocus this.attachShadow({delegatesFocus: true}); // serialized this.attachShadow({serializable: true}); // Manually assign an element to a slot this.attachShadow({slotAssignment: "manual"});

About that last one, it says we have to manually insert the <slot> elements in JavaScript:

<my-element> <p>This WILL render in shadow DOM but not automatically.</p> </my-element> <script> customElements.define('my-element', class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open", slotAssignment: "manual" }); this.shadowRoot.innerHTML = '<slot></slot>'; } connectedCallback(){ const slotElem = this.querySelector('p'); this.shadowRoot.querySelector('slot').assign(slotElem); } }); </script> Examples

Scott spent a great deal of time sharing examples that demonstrate different sorts of things you might want to do with the Shadow DOM when working with web components. I’ll rapid-fire those in here.

Get an array of element nodes in a slot this.shadowRoot.querySelector('slot') .assignedElements(); // get an array of all nodes in a slot, text too this.shadowRoot.querySelector('slot') .assignedNodes(); When did a slot’s nodes change? let slot = document.querySelector('div') .shadowRoot.querySelector("slot"); slot.addEventListener("slotchange", (e) => { console.log(`Slot "${slot.name}" changed`); // > Slot "saying" changed }) Combining imperative Shadow DOM with templates

Back to this example:

<my-status>available</my-status> <script> customElements.define('my-status', class extends HTMLElement { constructor() { super(); this.attachShadow({mode: "open"}); this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>'; } }); </script>

Let’s get that string out of our JavaScript with reusable imperative shadow HTML:

<my-status>available</my-status> <template id="my-status"> <p>This item is currently: <slot></slot> </p> </template> <script> customElements.define('my-status', class extends HTMLElement { constructor(){ super(); this.attachShadow({mode: 'open'}); const template = document.getElementById('my-status'); this.shadowRoot.append(template.content.cloneNode(true)); } }); </script>

Slightly better as it grabs the component’s name programmatically to prevent name collisions:

<my-status>available</my-status> <template id="my-status"> <p>This item is currently: <slot></slot> </p> </template> <script> customElements.define('my-status', class extends HTMLElement { constructor(){ super(); this.attachShadow({mode: 'open'}); const template = document.getElementById( this.nodeName.toLowerCase() ); this.shadowRoot.append(template.content.cloneNode(true)); } }); </script> Forms with Shadow DOM

Long story, cut short: maybe don’t create custom form controls as web components. We get a lot of free features and functionalities — including accessibility — with native form controls that we have to recreate from scratch if we decide to roll our own.

In the case of forms, one of the oddities of encapsulation is that form submissions are not automatically connected. Let’s look at a broken form that contains a web component for a custom input:

<form> <my-input> <template shadowrootmode="open"> <label> <slot></slot> <input type="text" name="your-name"> </label> </template> Type your name! </my-input> <label><input type="checkbox" name="remember">Remember Me</label> <button>Submit</button> </form> <script> document.forms[0].addEventListener('input', function(){ let data = new FormData(this); console.log(new URLSearchParams(data).toString()); }); </script>

This input’s value won’t be in the submission! Also, form validation and states are not communicated in the Shadow DOM. Similar connectivity issues with accessibility, where the shadow boundary can interfere with ARIA. For example, IDs are local to the Shadow DOM. Consider how much you really need the Shadow DOM when working with forms.

Element internals

The moral of the last section is to tread carefully when creating your own web components for form controls. Scott suggests avoiding that altogether, but he continued to demonstrate how we could theoretically fix functional and accessibility issues using element internals.

Let’s start with an input value that will be included in the form submission.

<form> <my-input name="name"></my-input> <button>Submit</button> </form>

Now let’s slot this imperatively:

<script> customElements.define('my-input', class extends HTMLElement { constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>' } }); </script>

The value is not communicated yet. We’ll add a static formAssociated variable with internals attached:

<script> customElements.define('my-input', class extends HTMLElement { static formAssociated = true; constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>' this.internals = this.attachedInternals(); } }); </script>

Then we’ll set the form value as part of the internals when the input’s value changes:

<script> customElements.define('my-input', class extends HTMLElement { static formAssociated = true; constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>' this.internals = this.attachedInternals(); this.addEventListener('input', () => { this-internals.setFormValue(this.shadowRoot.querySelector('input').value); }); } }); </script>

Here’s how we set states with element internals:

// add a checked state this.internals.states.add("checked"); // remove a checked state this.internals.states.delete("checked");

Let’s toggle a “add” or “delete” a boolean state:

<form> <my-check name="remember">Remember Me?</my-check> </form> <script> customElements.define('my-check', class extends HTMLElement { static formAssociated = true; constructor(){ super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = '<slot></slot>'; this.internals = this.attachInternals(); let addDelete = false; this.addEventListener("click", ()=> { addDelete = !addDelete; this.internals.states[addDelete ? "add" : "delete"]("checked"); } ); } }); </script>

Let’s refactor this for ARIA improvements:

<form> <style> my-check { display: inline-block; inline-size: 1em; block-size: 1em; background: #eee; } my-check:state(checked)::before { content: "[x]"; } </style> <my-check name="remember" id="remember"></my-check><label for="remember">Remember Me?</label> </form> <script> customElements.define('my-check', class extends HTMLElement { static formAssociated = true; constructor(){ super(); this.attachShadow({mode: 'open'}); this.internals = this.attachInternals(); this.internals.role = 'checkbox'; this.setAttribute('tabindex', '0'); let addDelete = false; this.addEventListener("click", ()=> { addDelete = !addDelete; this.internals.states[addDelete ? "add" : "delete"]("checked"); this[addDelete ? "setAttribute" : "removeAttribute"]("aria-checked", true); }); } }); </script>

Phew, that’s a lot of work! And sure, this gets us a lot closer to a more functional and accessible custom form input, but there’s still a long way’s to go to achieve what we already get for free from using native form controls. Always question whether you can rely on a light DOM form instead.

Chapter 5: Styling Web Components

Styling web components comes in levels of complexity. For example, we don’t need any JavaScript at all to slap a few styles on a custom element.

<my-element theme="suave" class="priority"> <h1>I'm in the Light DOM!</h1> </my-element> <style> /* Element, class, attribute, and complex selectors all work. */ my-element { display: block; /* custom elements are inline by default */ } .my-element[theme=suave] { color: #fff; } .my-element.priority { background: purple; } .my-element h1 { font-size: 3rem; } </style>
  • This is not encapsulated! This is scoped off of a single element just light any other CSS in the Light DOM.
  • Changing the Shadow DOM mode from closed to open doesn’t change CSS. It allows JavaScript to pierce the Shadow DOM but CSS isn’t affected.
Let’s poke at it <style> p { color: red; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <p>Hi</p> </template> </div> <p>Hi</p>
  • This is three stacked paragraphs, the second of which is in the shadow root.
  • The first and third paragraphs are red; the second is not styled because it is in a <template>, even if the shadow root’s mode is set to open.

Let’s poke at it from the other direction:

<style> p { color: red; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> p { color: blue;} </style> <p>Hi</p> </template> </div> <p>Hi</p>
  • The first and third paragraphs are still receiving the red color from the Light DOM’s CSS.
  • The <style> declarations in the <template> are encapsulated and do not leak out to the other paragraphs, even though it is declared later in the cascade.

Same idea, but setting the color on the <body>:

<style> body { color: red; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <p>Hi</p> </template> </div> <p>Hi</p>
  • Everything is red! This isn’t a bug. Inheritable styles do pass through the Shadow DOM barrier.
  • Inherited styles are those that are set by the computed values of their parent styles. Many properties are inheritable, including color. The <body> is the parent and everything in it is a child that inherits these styles, including custom elements.
Let’s fight with inheritance

We can target the paragraph in the <template> style block to override the styles set on the <body>. Those won’t leak back to the other paragraphs.

<style> body { color: red; font-family: fantasy; font-size: 2em; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> /* reset the light dom styles */ p { color: initial; font-family: initial; font-size: initial; } </style> <p>Hi</p> </template> </div> <p>Hi</p>
  • This is protected, but the problem here is that it’s still possible for a new role or property to be introduced that passes along inherited styles that we haven’t thought to reset.
  • Perhaps we could use all: initital as a defensive strategy against future inheritable styles. But what if we add more elements to the custom element? It’s a constant fight.
Host styles!

We can scope things to the shadow root’s :host selector to keep things protected.

<style> body { color: red; font-family: fantasy; font-size: 2em; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> /* reset the light dom styles */ :host { all: initial; } </style> <p>Hi</p> <a href="#">Click me</a> </template> </div> <p>Hi</p>

New problem! What if the Light DOM styles are scoped to the universal selector instead?

<style> * { color: red; font-family: fantasy; font-size: 2em; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> /* reset the light dom styles */ :host { all: initial; } </style> <p>Hi</p> <a href="#">Click me</a> </template> </div> <p>Hi</p>

This breaks the custom element’s styles. But that’s because Shadow DOM styles are applied before Light DOM styles. The styles scoped to the universal selector are simply applied after the :host styles, which overrides what we have in the shadow root. So, we’re still locked in a brutal fight over inheritance and need stronger specificity.

According to Scott, !important is one of the only ways we have to apply brute force to protect our custom elements from outside styles leaking in. The keyword gets a bad rap — and rightfully so in the vast majority of cases — but this is a case where it works well and using it is an encouraged practice. It’s not like it has an impact on the styles outside the custom element, anyway.

<style> * { color: red; font-family: fantasy; font-size: 2em; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> /* reset the light dom styles */ :host { all: initial; !important } </style> <p>Hi</p> <a href="#">Click me</a> </template> </div> <p>Hi</p> Special selectors

There are some useful selectors we have to look at components from the outside, looking in.

:host()

We just looked at this! But note how it is a function in addition to being a pseudo-selector. It’s sort of a parent selector in the sense that we can pass in the <div> that contains the <template> and that becomes the scoping context for the entire selector, meaning the !important keyword is no longer needed.

<style> * { color: red; font-family: fantasy; font-size: 2em; } </style> <p>Hi</p> <div> <template shadowrootmode="open"> <style> /* reset the light dom styles */ :host(div) { all: initial; } </style> <p>Hi</p> <a href="#">Click me</a> </template> </div> <p>Hi</p> :host-context() <header> <my-element> <template shadowrootmode="open"> <style> :host-context(header) { ... } /* matches the host! */ </style> </template> </my-element> </header>

This targets the shadow host but only if the provided selector is a parent node anywhere up the tree. This is super helpful for styling custom elements where the layout context might change, say, from being contained in an <article> versus being contained in a <header>.

:defined

Defining an element occurs when it is created, and this pseudo-selector is how we can select the element in that initially-defined state. I imagine this is mostly useful for when a custom element is defined imperatively in JavaScript so that we can target the very moment that the element is constructed, and then set styles right then and there.

<style> simple-custom:defined { display: block; background: green; color: #fff; } </style> <simple-custom></simple-custom> <script> customElements.define('simple-custom', class extends HTMLElement { constructor(){ super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = "<p>Defined!</p>"; } }); </script>

Minor note about protecting against a flash of unstyled content (FOUC)… or unstyled element in this case. Some elements are effectively useless until JavsScript has interacted with it to generate content. For example, an empty custom element that only becomes meaningful once JavaScript runs and generates content. Here’s how we can prevent the inevitable flash that happens after the content is generated:

<style> js-dependent-element:not(:defined) { visibility: hidden; } </style> <js-dependent-element></js-dependent-element>

Warning zone! It’s best for elements that are empty and not yet defined. If you’re working with a meaningful element up-front, then it’s best to style as much as you can up-front.

Styling slots

This does not style the paragraph green as you might expect:

<div> <template shadowrootmode="open"> <style> p { color: green; } </style> <slot></slot> </template> <p>Slotted Element</p> </div>

The Shadow DOM cannot style this content directly. The styles would apply to a paragraph in the <template> that gets rendered in the Light DOM, but it cannot style it when it is slotted into the <template>.

Slots are part of the Light DOM. So, this works:

<style> p { color: green; } </style> <div> <template shadowrootmode="open"> <slot></slot> </template> <p>Slotted Element</p> </div>

This means that slots are easier to target when it comes to piercing the shadow root with styles, making them a great method of progressive style enhancement.

We have another special selected, the ::slotted() pseudo-element that’s also a function. We pass it an element or class and that allows us to select elements from within the shadow root.

<div> <template shadowrootmode="open"> <style> ::slotted(p) { color: red; } </style> <slot></slot> </template> <p>Slotted Element</p> </div>

Unfortunately, ::slotted() is a weak selected when compared to global selectors. So, if we were to make this a little more complicated by introducing an outside inheritable style, then we’d be hosed again.

<style> /* global paragraph style... */ p { color: green; } </style> <div> <template shadowrootmode="open"> <style> /* ...overrides the slotted style */ ::slotted(p) { color: red; } </style> <slot></slot> </template> <p>Slotted Element</p> </div>

This is another place where !important could make sense. It even wins if the global style is also set to !important. We could get more defensive and pass the universal selector to ::slotted and set everything back to its initial value so that all slotted content is encapsulated from outside styles leaking in.

<style> /* global paragraph style... */ p { color: green; } </style> <div> <template shadowrootmode="open"> <style> /* ...can't override this important statement */ ::slotted(*) { all: initial !important; } </style> <slot></slot> </template> <p>Slotted Element</p> </div> Styling :parts

A part is a way of offering up Shadow DOM elements to the parent document for styling. Let’s add a part to a custom element:

<div> <template shadowrootmode="open"> <p part="hi">Hi there, I'm a part!</p> </template> </div>

Without the part attribute, there is no way to write styles that reach the paragraph. But with it, the part is exposed as something that can be styled.

<style> ::part(hi) { color: green; } ::part(hi) b { color: green; } /* nope! */ </style> <div> <template shadowrootmode="open"> <p part="hi">Hi there, I'm a <b>part</b>!</p> </template> </div>

We can use this to expose specific “parts” of the custom element that are open to outside styling, which is almost like establishing a styling API with specifications for what can and can’t be styled. Just note that ::part cannot be used as part of a complex selector, like a descendant selector:

A bit in the weeds here, but we can export parts in the sense that we can nest elements within elements within elements, and so on. This way, we include parts within elements.

<my-component> <!-- exposes three parts to the nested component --> <nested-component exportparts="part1, part2, part5"></nested-component> </my-component> Styling states and validity

We discussed this when going over element internals in the chapter about the Shadow DOM. But it’s worth revisiting that now that we’re specifically talking about styling. We have a :state pseudo-function that accepts our defined states.

<script> this.internals.states.add("checked"); </script> <style> my-checkbox:state(checked) { /* ... */ } </style>

We also have access to the :invalid pseudo-class.

Cross-barrier custom properties <style> :root { --text-primary: navy; --bg-primary: #abe1e1; --padding: 1.5em 1em; } p { color: var(--text-primary); background: var(--bg-primary); padding: var(--padding); } </style>

Custom properties cross the Shadow DOM barrier!

<my-elem></my-elem> <script> customElements.define('my-elem', class extends HTMLElement { constructor(){ super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <style> p { color: var(--text-primary); background: var(--bg-primary); padding: var(--padding); } </style> <p>Hi there!</p>`; } }) </script> Adding stylesheets to custom elements

There’s the classic ol’ external <link> way of going about it:

<simple-custom> <template shadowrootmode="open"> <link rel="stylesheet" href="../../assets/external.css"> <p>This one's in the shadow Dom.</p> <slot></slot> </template> <p>Slotted <b>Element</b></p> </simple-custom>

It might seem like an anti-DRY approach to call the same external stylesheet at the top of all web components. To be clear, yes, it is repetitive — but only as far as writing it. Once the sheet has been downloaded once, it is available across the board without any additional requests, so we’re still technically dry in the sense of performance.

CSS imports also work:

<style> @import url("../../assets/external.css"); </style> <simple-custom> <template shadowrootmode="open"> <style> @import url("../../assets/external.css"); </style> <p>This one's in the shadow Dom.</p> <slot></slot> </template> <p>Slotted <b>Element</b></p> </simple-custom>

One more way using a JavaScript-based approach. It’s probably better to make CSS work without a JavaScript dependency, but it’s still a valid option.

<my-elem></my-elem> <script type="module"> import sheet from '../../assets/external.css' with { type: 'css' }; customElements.define('my-elem', class extends HTMLElement { constructor(){ super(); this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = '<p>Hi there</p>'; this.shadowRoot.adoptedStyleSheets = [sheet]; } }) </script>

We have a JavaScript module and import CSS into a string that is then adopted by the shadow root using shadowRoort.adoptedStyleSheets . And since adopted stylesheets are dynamic, we can construct one, share it across multiple instances, and update styles via the CSSOM that ripple across the board to all components that adopt it.

Container queries!

Container queries are nice to pair with components, as custom elements and web components are containers and we can query them and adjust things as the container changes.

<div> <template shadowrootmode="open"> <style> :host { container-type: inline-size; background-color: tan; display: block; padding: 2em; } ul { display: block; list-style: none; margin: 0; } li { padding: .5em; margin: .5em 0; background-color: #fff; } @container (min-width: 50em) { ul { display: flex; justify-content: space-between; gap: 1em; } li { flex: 1 1 auto; } } </style> <ul> <li>First Item</li> <li>Second Item</li> </ul> </template> </div>

In this example, we’re setting styles on the :host() to define a new container, as well as some general styles that are protected and scoped to the shadow root. From there, we introduce a container query that updates the unordered list’s layout when the custom element is at least 50em wide.

Next up…

How web component features are used together!

Chapter 6: HTML-First Patterns

In this chapter, Scott focuses on how other people are using web components in the wild and highlights a few of the more interesting and smart patterns he’s seen.

Let’s start with a typical counter

It’s often the very first example used in React tutorials.

<counter-element></counter-element> <script type="module"> customElements.define('counter-element', class extends HTMLElement { #count = 0; connectedCallback() { this.innerHTML = `<button id="dec">-</button><p id="count">${this.#count}</p><button id="inc">+</button>`; this.addEventListener('click', e => this.update(e) ); } update(e) { if( e.target.nodeName !== 'BUTTON' ) { return } this.#count = e.target.id === 'inc' ? this.#count + 1 : this.#count - 1; this.querySelector('#count').textContent = this.#count; } }); </script> Reef

Reef is a tiny library by Chris Ferdinandi that weighs just 2.6KB minified and zipped yet still provides DOM diffing for reactive state-based UIs like React, which weighs significantly more. An example of how it works in a standalone way:

<div id="greeting"></div> <script type="module"> import {signal, component} from '.../reef.es..min.js'; // Create a signal let data = signal({ greeting: 'Hello', name: 'World' }); component('#greeting', () => `<p>${data.greeting}, ${data.name}!</p>`); </script>

This sets up a “signal” that is basically a live-update object, then calls the component() method to select where we want to make the update, and it injects a template literal in there that passes in the variables with the markup we want.

So, for example, we can update those values on setTimeout:

<div id="greeting"></div> <script type="module"> import {signal, component} from '.../reef.es..min.js'; // Create a signal let data = signal({ greeting: 'Hello', name: 'World' }); component('#greeting', () => `<p>${data.greeting}, ${data.name}!</p>`); setTimeout(() => { data.greeting = '¡Hola' data,name = 'Scott' }, 3000) </script>

We can combine this sort of library with a web component. Here, Scott imports Reef and constructs the data outside the component so that it’s like the application state:

<my-greeting></my-greeting> <script type="module"> import {signal, component} from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js'; window.data = signal({ greeting: 'Hi', name: 'Scott' }); customElements.define('my-greeting', class extends HTMLElement { connectedCallback(){ component(this, () => `<p>${data.greeting}, ${data.name}!</p>` ); } }); </script>

It’s the virtual DOM in a web component! Another approach that is more reactive in the sense that it watches for changes in attributes and then updates the application state in response which, in turn, updates the greeting.

<my-greeting greeting="Hi" name="Scott"></my-greeting> <script type="module"> import {signal, component} from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js'; customElements.define('my-greeting', class extends HTMLElement { static observedAttributes = ["name", "greeting"]; constructor(){ super(); this.data = signal({ greeting: '', name: '' }); } attributeChangedCallback(name, oldValue, newValue) { this.data[name] = newValue; } connectedCallback(){ component(this, () => `<p>${this.data.greeting}, ${this.data.name}!</p>` ); } }); </script>

If the attribute changes, it only changes that instance. The data is registered at the time the component is constructed and we’re only changing string attributes rather than objects with properties.

HTML Web Components

This describes web components that are not empty by default like this:

<my-greeting></my-greeting>

This is a “React” mindset where all the functionality, content, and behavior comes from JavaScript. But Scott reminds us that web components are pretty useful right out of the box without JavaScript. So, “HTML web components” refers to web components that are packed with meaningful content right out of the gate and Scott points to Jeremy Keith’s 2023 article coining the term.

[…] we could call them “HTML web components.” If your custom element is empty, it’s not an HTML web component. But if you’re using a custom element to extend existing markup, that’s an HTML web component.

Jeremy cites something Robin Rendle mused about the distinction:

[…] I’ve started to come around and see Web Components as filling in the blanks of what we can do with hypertext: they’re really just small, reusable chunks of code that extends the language of HTML.

The “React” way:

<UserAvatar src="https://example.com/path/to/img.jpg" alt="..." />

The props look like HTML but they’re not. Instead, the props provide information used to completely swap out the <UserAvatar /> tag with the JavaScript-based markup.

Web components can do that, too:

<user-avatar src="https://example.com/path/to/img.jpg" alt="..." ></user-avatar>

Same deal, real HTML. Progressive enhancement is at the heart of an HTML web component mindset. Here’s how that web component might work:

class UserAvatar extends HTMLElement { connectedCallback() { const src = this.getAttribute("src"); const name = this.getAttribute("name"); this.innerHTML = ` <div> <img src="${src}" alt="Profile photo of ${name}" width="32" height="32" /> <!-- Markup for the tooltip --> </div> `; } } customElements.define('user-avatar', UserAvatar);

But a better starting point would be to include the <img> directly in the component so that the markup is immediately available:

<user-avatar> <img src="https://example.com/path/to/img.jpg" alt="..." /> </user-avatar>

This way, the image is downloaded and ready before JavaScript even loads on the page. Strive for augmentation over replacement!

resizeasaurus

This helps developers test responsive component layouts, particularly ones that use container queries.

<resize-asaurus> Drop any HTML in here to test. </resize-asaurus> <!-- for example: --> <resize-asaurus> <div class="my-responsive-grid"> <div>Cell 1</div> <div>Cell 2</div> <div>Cell 3</div> <!-- ... --> </div> </resize-asaurus> lite-youtube-embed

This is like embedding a YouTube video, but without bringing along all the baggage that YouTube packs into a typical embed snippet.

<lite-youtube videoid="ogYfd705cRs" style="background-image: url(...);"> <a href="https://youtube.com/watch?v=ogYfd705cRs" class="lyt-playbtn" title="Play Video"> <span class="lyt-visually-hidden">Play Video: Keynote (Google I/O '18)</span> </a> </lite-youtube> <link rel="stylesheet" href="./src.lite-yt-embed.css" /> <script src="./src.lite-yt-embed.js" defer></script>

It starts with a link which is a nice fallback if the video fails to load for whatever reason. When the script runs, the HTML is augmented to include the video <iframe>.

Chapter 7: Web Components Frameworks Tour Lit

Lit extends the base class and then extends what that class provides, but you’re still working directly on top of web components. There are syntax shortcuts for common patterns and a more structured approach.

The package includes all this in about 5-7KB:

  • Fast templating
  • Reactive properties
  • Reactive update lifecycle
  • Scoped styles
<simple-greeting name="Geoff"></simple-greeting> <script> import {html, css, LitElement} from 'lit'; export class SimpleGreeting extends LitElement { state styles = css`p { color: blue }`; static properties = { name: {type = String}, }; constructor() { super(); this.name = 'Somebody'; } render() { return html`<p>Hello, ${this.name}!</p>`; } } customElements.define('simple-greeting', SimpleGreeting); </script> ProsConsEcosystemNo official SSR story (but that is changing)CommunityFamiliar ergonomicsLightweightIndustry-proven webc

This is part of the 11ty project. It allows you to define custom elements as files, writing everything as a single file component.

<!-- starting element / index.html --> <my-element></my-element> <!-- ../components/my-element.webc --> <p>This is inside the element</p> <style> /* etc. */ </style> <script> // etc. </script> ProsConsCommunityGeared toward SSGSSG progressive enhancementStill in early stagesSingle file component syntaxZach Leatherman! Enhance

This is Scott’s favorite! It renders web components on the server. Web components can render based on application state per request. It’s a way to use custom elements on the server side. 

ProsConsErgonomicsStill in early stagesProgressive enhancementSingle file component syntaxFull-stack stateful, dynamic SSR components Chapter 8: Web Components Libraries Tour

This is a super short module simply highlighting a few of the more notable libraries for web components that are offered by third parties. Scott is quick to note that all of them are closer in spirit to a React-based approach where custom elements are more like replaced elements with very little meaningful markup to display up-front. That’s not to throw shade at the libraries, but rather to call out that there’s a cost when we require JavaScript to render meaningful content.

Spectrum <sp-button variant="accent" href="components/button"> Use Spectrum Web Component buttons </sp-button>
  • This is Adobe’s design system.
  • One of the more ambitious projects, as it supports other frameworks like React
  • Open source
  • Built on Lit

Most components are not exactly HTML-first. The pattern is closer to replaced elements. There’s plenty of complexity, but that makes sense for a system that drives an application like Photoshop and is meant to drop into any project. But still, there is a cost when it comes to delivering meaningful content to users up-front. An all-or-nothing approach like this might be too stark for a small website project.

FAST <fast-checkbox>Checkbox</fast-checkbox>
  • This is Microsoft’s system.
  • It’s philosophically like Spectrum where there’s very little meaningful HTML up-front.
  • Fluent is a library that extends the system for UI components.
  • Microsoft Edge rebuilt the browser’s Chrome using these components.
Shoelace <sl-button>Click Me</sl-button>
  • Purely meant for third-party developers to use in their projects
  • The name is a play on Bootstrap. &#x1f642;
  • The markup is mostly a custom element with some text in it rather than a pure HTML-first approach.
  • Acquired by Font Awesome and they are creating Web Awesome Components as a new era of Shoelace that is subscription-based
Chapter 9: What’s Next With Web Components

Scott covers what the future holds for web components as far as he is aware.

Declarative custom elements

Define an element in HTML alone that can be used time and again with a simpler syntax. There’s a GitHub issue that explains the idea, and Zach Leatherman has a great write-up as well.

GitHub Issue Cross-root ARIA

Make it easier to pair custom elements with other elements in the Light DOM as well as other custom elements through ARIA.

GitHub Explainer GitHub Proposal Container Queries

How can we use container queries without needing an extra wrapper around the custom element?

HTML Modules

This was one of the web components’ core features but was removed at some point. They can define HTML in an external place that could be used over and over.

GitHub Explainer External styling

This is also known as “open styling.”

GitHub Explainer DOM Parts

This would be a templating feature that allows for JSX-string-literal-like syntax where variables inject data.

<section> <h1 id="name">{name}</h1> Email: <a id="link" href="mailto:{email}">{email}</a> </section>

And the application has produced a template with the following content:

<template> <section> <h1 id="name">{{}}</h1> Email: <a id="link" href="{{}}">{{}}</a> </section> </template> GitHub Proposal Scoped element registries

Using variations of the same web component without name collisions.

GitHub Issue

Web Components Demystified originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Powering Search With Astro Actions and Fuse.js

Css Tricks - Tue, 03/11/2025 - 5:26am

Static sites are wonderful. I’m a big fan.

They also have their issues. Namely, static sites either are purely static or the frameworks that generate them completely lose out on true static generation when you just dip your toes in the direction of server routes.

Astro has been watching the front-end ecosystem and is trying to keep one foot firmly embedded in pure static generation, and the other in a powerful set of server-side functionality.

With Astro Actions, Astro brings a lot of the power of the server to a site that is almost entirely static. A good example of this sort of functionality is dealing with search. If you have a content-based site that can be purely generated, adding search is either going to be something handled entirely on the front end, via a software-as-a-service solution, or, in other frameworks, converting your entire site to a server-side application.

With Astro, we can generate most of our site during our build, but have a small bit of server-side code that can handle our search functionality using something like Fuse.js.

In this demo, we’ll use Fuse to search through a set of personal “bookmarks” that are generated at build time, but return proper results from a server call.

GitHub Live Demo Starting the project

To get started, we’ll just set up a very basic Astro project. In your terminal, run the following command:

npm create astro@latest

Astro’s adorable mascot Houston is going to ask you a few questions in your terminal. Here are the basic responses, you’ll need:

  • Where should we create your new project? Wherever you’d like, but I’ll be calling my directory ./astro-search
  • How would you like to start your new project? Choose the basic minimalist starter.
  • Install dependencies? Yes, please!
  • Initialize a new git repository? I’d recommend it, personally!

This will create a directory in the location specified and install everything you need to start an Astro project. Open the directory in your code editor of choice and run npm run dev in your terminal in the directory.

When you run your project, you’ll see the default Astro project homepage.

We’re ready to get our project rolling!

Basic setup

To get started, let’s remove the default content from the homepage. Open the  /src/pages/index.astro file.

This is a fairly barebones homepage, but we want it to be even more basic. Remove the <Welcome /> component, and we’ll have a nice blank page.

For styling, let’s add Tailwind and some very basic markup to the homepage to contain our site.

npx astro add tailwind

The astro add command will install Tailwind and attempt to set up all the boilerplate code for you (handy!). The CLI will ask you if you want it to add the various components, I recommend letting it, but if anything fails, you can copy the code needed from each of the steps in the process. As the last step for getting to work with Tailwind, the CLI will tell you to import the styles into a shared layout. Follow those instructions, and we can get to work.

Let’s add some very basic markup to our new homepage.

--- // ./src/pages/index.astro import Layout from '../layouts/Layout.astro'; --- <Layout> <div class="max-w-3xl mx-auto my-10"> <h1 class="text-3xl text-center">My latest bookmarks</h1> <p class="text-xl text-center mb-5">This is only 10 of A LARGE NUMBER THAT WE'LL CHANGE LATER</p> </div> </Layout>

Your site should now look like this.

Not exactly winning any awards yet! That’s alright. Let’s get our bookmarks loaded in.

Adding bookmark data with Astro Content Layer

Since not everyone runs their own application for bookmarking interesting items, you can borrow my data. Here’s a small subset of my bookmarks, or you can go get 110 items from this link on GitHub. Add this data as a file in your project. I like to group data in a data directory, so my file lives in /src/data/bookmarks.json.

Open code [ { "pageTitle": "Our Favorite Sandwich Bread | King Arthur Baking", "url": "<https://www.kingarthurbaking.com/recipes/our-favorite-sandwich-bread-recipe>", "description": "Classic American sandwich loaf, perfect for French toast and sandwiches.", "id": "007y8pmEOvhwldfT3wx1MW" }, { "pageTitle": "Chris Coyier's discussion of Automatic Social Share Images | CSS-Tricks ", "url": "<https://css-tricks.com/automatic-social-share-images/>", "description": "It's a pretty low-effort thing to get a big fancy link preview on social media. Toss a handful of specific <meta> tags on a URL and you get a big image-title-description thing ", "id": "04CXDvGQo19m0oXERL6bhF" }, { "pageTitle": "Automatic Social Share Images | ryanfiller.com", "url": "<https://www.ryanfiller.com/blog/automatic-social-share-images/>", "description": "Setting up automatic social share images with Puppeteer and Netlify Functions. ", "id": "04CXDvGQo19m0oXERLoC10" }, { "pageTitle": "Emma Wedekind: Foundations of Design Systems / React Boston 2019 - YouTube", "url": "<https://m.youtube.com/watch?v=pXb2jA43A6k>", "description": "Emma Wedekind: Foundations of Design Systems / React Boston 2019 Presented by: Emma Wedekind – LogMeIn Design systems are in the world around us, from street...", "id": "0d56d03e-aba4-4ebd-9db8-644bcc185e33" }, { "pageTitle": "Editorial Design Patterns With CSS Grid And Named Columns — Smashing Magazine", "url": "<https://www.smashingmagazine.com/2019/10/editorial-design-patterns-css-grid-subgrid-naming/>", "description": "By naming lines when setting up our CSS Grid layouts, we can tap into some interesting and useful features of Grid — features that become even more powerful when we introduce subgrids.", "id": "13ac1043-1b7d-4a5b-a3d8-b6f5ec34cf1c" }, { "pageTitle": "Netlify pro tip: Using Split Testing to power private beta releases - DEV Community &#x1f469;‍&#x1f4bb;&#x1f468;‍&#x1f4bb;", "url": "<https://dev.to/philhawksworth/netlify-pro-tip-using-split-testing-to-power-private-beta-releases-a7l>", "description": "Giving users ways to opt in and out of your private betas. Video and tutorial.", "id": "1fbabbf9-2952-47f2-9005-25af90b0229e" }, { "pageTitle": "Netlify Public Folder, Part I: What? Recreating the Dropbox Public Folder With Netlify | Jim Nielsen’s Weblog", "url": "<https://blog.jim-nielsen.com/2019/netlify-public-folder-part-i-what/>", "id": "2607e651-7b64-4695-8af9-3b9b88d402d5" }, { "pageTitle": "Why Is CSS So Weird? - YouTube", "url": "<https://m.youtube.com/watch?v=aHUtMbJw8iA&feature=youtu.be>", "description": "Love it or hate it, CSS is weird! It doesn't work like most programming languages, and it doesn't work like a design tool either. But CSS is also solving a v...", "id": "2e29aa3b-45b8-4ce4-85b7-fd8bc50daccd" }, { "pageTitle": "Internet world despairs as non-profit .org sold for $$$$ to private equity firm, price caps axed • The Register", "url": "<https://www.theregister.co.uk/2019/11/20/org_registry_sale_shambles/>", "id": "33406b33-c453-44d3-8b18-2d2ae83ee73f" }, { "pageTitle": "Netlify Identity for paid subscriptions - Access Control / Identity - Netlify Community", "url": "<https://community.netlify.com/t/netlify-identity-for-paid-subscriptions/1947/2>", "description": "I want to limit certain functionality on my website to paying users. Now I’m using a payment provider (Mollie) similar to Stripe. My idea was to use the webhook fired by this service to call a Netlify function and give…", "id": "34d6341c-18eb-4744-88e1-cfbf6c1cfa6c" }, { "pageTitle": "SmashingConf Freiburg 2019: Videos And Photos — Smashing Magazine", "url": "<https://www.smashingmagazine.com/2019/10/smashingconf-freiburg-2019/>", "description": "We had a lovely time at SmashingConf Freiburg. This post wraps up the event and also shares the video of all of the Freiburg presentations.", "id": "354cbb34-b24a-47f1-8973-8553ed1d809d" }, { "pageTitle": "Adding Google Calendar to your JAMStack", "url": "<https://www.raymondcamden.com/2019/11/18/adding-google-calendar-to-your-jamstack>", "description": "A look at using Google APIs to add events to your static site.", "id": "361b20c4-75ce-46b3-b6d9-38139e03f2ca" }, { "pageTitle": "How to Contribute to an Open Source Project | CSS-Tricks", "url": "<https://css-tricks.com/how-to-contribute-to-an-open-source-project/>", "description": "The following is going to get slightly opinionated and aims to guide someone on their journey into open source. As a prerequisite, you should have basic", "id": "37300606-af08-4d9a-b5e3-12f64ebbb505" }, { "pageTitle": "Functions | Netlify", "url": "<https://www.netlify.com/docs/functions/>", "description": "Netlify builds, deploys, and hosts your front end. Learn how to get started, see examples, and view documentation for the modern web platform.", "id": "3bf9e31b-5288-4b3b-89f2-97034603dbf6" }, { "pageTitle": "Serverless Can Help You To Focus - By Simona Cotin", "url": "<https://hackernoon.com/serverless-can-do-that-7nw32mk>", "id": "43b1ee63-c2f8-4e14-8700-1e21c2e0a8b1" }, { "pageTitle": "Nuxt, Next, Nest?! My Head Hurts. - DEV Community &#x1f469;‍&#x1f4bb;&#x1f468;‍&#x1f4bb;", "url": "<https://dev.to/laurieontech/nuxt-next-nest-my-head-hurts-5h98>", "description": "I clearly know what all of these things are. Their names are not at all similar. But let's review, just to make sure we know...", "id": "456b7d6d-7efa-408a-9eca-0325d996b69c" }, { "pageTitle": "Consuming a headless CMS GraphQL API with Eleventy - Webstoemp", "url": "<https://www.webstoemp.com/blog/headless-cms-graphql-api-eleventy/>", "description": "With Eleventy, consuming data coming from a GraphQL API to generate static pages is as easy as using Markdown files.", "id": "4606b168-21a6-49df-8536-a2a00750d659" }, ]

Now that the data is in the project, we need for Astro to incorporate the data into its build process. To do this, we can use Astro’s new(ish) Content Layer API. The Content Layer API adds a content configuration file to your src directory that allows you to run and collect any number of content pieces from data in your project or external APIs. Create the file  /src/content.config.ts (the name of this file matters, as this is what Astro is looking for in your project).

import { defineCollection, z } from "astro:content"; import { file } from 'astro/loaders'; const bookmarks = defineCollection({ schema: z.object({ pageTitle: z.string(), url: z.string(), description: z.string().optional() }), loader: file("src/data/bookmarks.json"), }); export const collections = { bookmarks };

In this file, we import a few helpers from Astro. We can use defineCollection to create the collection, z as Zod, to help define our types, and file is a specific content loader meant to read data files.

The defineCollection method takes an object as its argument with a required loader and optional schema. The schema will help make our content type-safe and make sure our data is always what we expect it to be. In this case, we’ll define the three data properties each of our bookmarks has. It’s important to define all your data in your schema, otherwise it won’t be available to your templates.

We provide the loader property with a content loader. In this case, we’ll use the file loader that Astro provides and give it the path to our JSON.

Finally, we need to export the collections variable as an object containing all the collections that we’ve defined (just bookmarks in our project). You’ll want to restart the local server by re-running npm run dev in your terminal to pick up the new data.

Using the new bookmarks content collection

Now that we have data, we can use it in our homepage to show the most recent bookmarks that have been added. To get the data, we need to access the content collection with the getCollection method from astro:content. Add the following code to the frontmatter for ./src/pages/index.astro .

--- import Layout from '../layouts/Layout.astro'; import { getCollection } from 'astro:content'; const bookmarks = await getCollection('bookmarks'); ---

This code imports the getCollection method and uses it to create a new variable that contains the data in our bookmarkscollection. The bookmarks variable is an array of data, as defined by the collection, which we can use to loop through in our template.

--- import Layout from '../layouts/Layout.astro'; import { getCollection } from 'astro:content'; const bookmarks = await getCollection('bookmarks'); --- <Layout> <div class="max-w-3xl mx-auto my-10"> <h1 class="text-3xl text-center">My latest bookmarks</h1> <p class="text-xl text-center mb-5"> This is only 10 of {bookmarks.length} </p> <h2 class="text-2xl mb-3">Latest bookmarks</h2> <ul class="grid gap-4"> { bookmarks.slice(0, 10).map((item) => ( <li> <a href={item.data?.url} class="block p-6 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"> <h3 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"> {item.data?.pageTitle} </h3> <p class="font-normal text-gray-700 dark:text-gray-400"> {item.data?.description} </p> </a> </li> )) } </ul> </div> </Layout>

This should pull the most recent 10 items from the array and display them on the homepage with some Tailwind styles. The main thing to note here is that the data structure has changed a little. The actual data for each item in our array actually resides in the data property of the item. This allows Astro to put additional data on the object without colliding with any details we provide in our database. Your project should now look something like this.

Now that we have data and display, let’s get to work on our search functionality.

Building search with actions and vanilla JavaScript

To start, we’ll want to scaffold out a new Astro component. In our example, we’re going to use vanilla JavaScript, but if you’re familiar with React or other frameworks that Astro supports, you can opt for client Islands to build out your search. The Astro actions will work the same.

Setting up the component

We need to make a new component to house a bit of JavaScript and the HTML for the search field and results. Create the component in a ./src/components/Search.astro file.

<form id="searchForm" class="flex mb-6 items-center max-w-sm mx-auto"> <label for="simple-search" class="sr-only">Search</label> <div class="relative w-full"> <input type="text" id="search" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search Bookmarks" required /> </div> <button type="submit" class="p-2.5 ms-2 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"> <svg class="w-4 h-4" aria-hidden="true" xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 20 20"> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path> </svg> <span class="sr-only">Search</span> </button> </form> <div class="grid gap-4 mb-10 hidden" id="results"> <h2 class="text-xl font-bold mb-2">Search Results</h2> </div> <script> const form = document.getElementById("searchForm"); const search = document.getElementById("search"); const results = document.getElementById("results"); form?.addEventListener("submit", async (e) => { e.preventDefault(); console.log("SEARCH WILL HAPPEN"); }); </script>

The basic HTML is setting up a search form, input, and results area with IDs that we’ll use in JavaScript. The basic JavaScript finds those elements, and for the form, adds an event listener that fires when the form is submitted. The event listener is where a lot of our magic is going to happen, but for now, a console log will do to make sure everything is set up properly.

Setting up an Astro Action for search

In order for Actions to work, we need our project to allow for Astro to work in server or hybrid mode. These modes allow for all or some pages to be rendered in serverless functions instead of pre-generated as HTML during the build. In this project, this will be used for the Action and nothing else, so we’ll opt for hybrid mode.

To be able to run Astro in this way, we need to add a server integration. Astro has integrations for most of the major cloud providers, as well as a basic Node implementation. I typically host on Netlify, so we’ll install their integration. Much like with Tailwind, we’ll use the CLI to add the package and it will build out the boilerplate we need.

npx astro add netlify

Once this is added, Astro is running in Hybrid mode. Most of our site is pre-generated with HTML, but when the Action gets used, it will run as a serverless function.

Setting up a very basic search Action

Next, we need an Astro Action to handle our search functionality. To create the action, we need to create a new file at ./src/actions/index.js. All our Actions live in this file. You can write the code for each one in separate files and import them into this file, but in this example, we only have one Action, and that feels like premature optimization.

In this file, we’ll set up our search Action. Much like setting up our content collections, we’ll use a method called defineAction and give it a schema and in this case a handler. The schema will validate the data it’s getting from our JavaScript is typed correctly, and the handler will define what happens when the Action runs.

import { defineAction } from "astro:actions"; import { z } from "astro:schema"; import { getCollection } from "astro:content"; export const server = { search: defineAction({ schema: z.object({ query: z.string(), }), handler: async (query) => { const bookmarks = await getCollection("bookmarks"); const results = await bookmarks.filter((bookmark) => { return bookmark.data.pageTitle.includes(query); }); return results; }, }), };

For our Action, we’ll name it search and expect a schema of an object with a single property named query which is a string. The handler function will get all of our bookmarks from the content collection and use a native JavaScript .filter() method to check if the query is included in any bookmark titles. This basic functionality is ready to test with our front-end.

Using the Astro Action in the search form event

When the user submits the form, we need to send the query to our new Action. Instead of figuring out where to send our fetch request, Astro gives us access to all of our server Actions with the actions object in astro:actions. This means that any Action we create is accessible from our client-side JavaScript.

In our Search component, we can now import our Action directly into the JavaScript and then use the search action when the user submits the form.

<script> import { actions } from "astro:actions"; const form = document.getElementById("searchForm"); const search = document.getElementById("search"); const results = document.getElementById("results"); form?.addEventListener("submit", async (e) => { e.preventDefault(); results.innerHTML = ""; const query = search.value; const { data, error } = await actions.search(query); if (error) { results.innerHTML = `<p>${error.message}</p>`; return; } // create a div for each search result data.forEach(( item ) => { const div = document.createElement("div"); div.innerHTML = ` <a href="${item.data?.url}" class="block p-6 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"> <h3 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"> ${item.data?.pageTitle} </h3> <p class="font-normal text-gray-700 dark:text-gray-400"> ${item.data?.description} </p> </a>`; // append the div to the results container results.appendChild(div); }); // show the results container results.classList.remove("hidden"); }); </script>

When results are returned, we can now get search results!

Though, they’re highly problematic. This is just a simple JavaScript filter, after all. You can search for “Favorite” and get my favorite bread recipe, but if you search for “favorite” (no caps), you’ll get an error… Not ideal.

That’s why we should use a package like Fuse.js.

Adding Fuse.js for fuzzy search

Fuse.js is a JavaScript package that has utilities to make “fuzzy” search much easier for developers. Fuse will accept a string and based on a number of criteria (and a number of sets of data) provide responses that closely match even when the match isn’t perfect. Depending on the settings, Fuse can match “Favorite”, “favorite”, and even misspellings like “favrite” all to the right results.

Is Fuse as powerful as something like Algolia or ElasticSearch? No. Is it free and pretty darned good? Absolutely! To get Fuse moving, we need to install it into our project.

npm install fuse.js

From there, we can use it in our Action by importing it in the file and creating a new instance of Fuse based on our bookmarks collection.

import { defineAction } from "astro:actions"; import { z } from "astro:schema"; import { getCollection } from "astro:content"; import Fuse from "fuse.js"; export const server = { search: defineAction({ schema: z.object({ query: z.string(), }), handler: async (query) => { const bookmarks = await getCollection("bookmarks"); const fuse = new Fuse(bookmarks, { threshold: 0.3, keys: [ { name: "data.pageTitle", weight: 1.0 }, { name: "data.description", weight: 0.7 }, { name: "data.url", weight: 0.3 }, ], }); const results = await fuse.search(query); return results; }, }), };

In this case, we create the Fuse instance with a few options. We give it a threshold value between 0 and 1 to decide how “fuzzy” to make the search. Fuzziness is definitely something that depends on use case and the dataset. In our dataset, I’ve found 0.3 to be a great threshold.

The keys array allows you to specify which data should be searched. In this case, I want all the data to be searched, but I want to allow for different weighting for each item. The title should be most important, followed by the description, and the URL should be last. This way, I can search for keywords in all these areas.

Once there’s a new Fuse instance, we run fuse.search(query) to have Fuse check the data, and return an array of results.

When we run this with our front-end, we find we have one more issue to tackle.

The structure of the data returned is not quite what it was with our simple JavaScript. Each result now has a refIndex and an item. All our data lives on the item, so we need to destructure the item off of each returned result.

To do that, adjust the front-end forEach.

// create a div for each search result data.forEach(({ item }) => { const div = document.createElement("div"); div.innerHTML = ` <a href="${item.data?.url}" class="block p-6 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"> <h3 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"> ${item.data?.pageTitle} </h3> <p class="font-normal text-gray-700 dark:text-gray-400"> ${item.data?.description} </p> </a>`; // append the div to the results container results.appendChild(div); });

Now, we have a fully working search for our bookmarks.

Next steps

This just scratches the surface of what you can do with Astro Actions. For instance, we should probably add additional error handling based on the error we get back. You can also experiment with handling this at the page-level and letting there be a Search page where the Action is used as a form action and handles it all as a server request instead of with front-end JavaScript code. You could also refactor the JavaScript from the admittedly low-tech vanilla JS to something a bit more robust with React, Svelte, or Vue.

One thing is for sure, Astro keeps looking at the front-end landscape and learning from the mistakes and best practices of all the other frameworks. Actions, Content Layer, and more are just the beginning for a truly compelling front-end framework.

Powering Search With Astro Actions and Fuse.js originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Smashing Meets Accessibility

Css Tricks - Mon, 03/10/2025 - 5:08am

The videos from Smashing Magazine’s recent event on accessibility were just posted the other day. I was invited to host the panel discussion with the speakers, including a couple of personal heroes of mine, Stéphanie Walter and Sarah Fossheim. But I was just as stoked to meet Kardo Ayoub who shared his deeply personal story as a designer with a major visual impairment.

I’ll drop the video here:

I’ll be the first to admit that I had to hold back my emotions as Kardo detailed what led to his impairment, the shock that came of it, and how he has beaten the odds to not only be an effective designer, but a real leader in the industry. It’s well worth watching his full presentation, which is also available on YouTube alongside the full presentations from Stéphanie and Sarah.

Smashing Meets Accessibility originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Syndicate content
©2003 - Present Akamai Design & Development.