Css Tricks

Syndicate content CSS-Tricks
Tips, Tricks, and Techniques on using Cascading Style Sheets.
Updated: 12 hours 8 min ago

CSS Bar Charts Using Modern Functions

Thu, 02/05/2026 - 5:13am

New CSS features can sometimes make it easier and more efficient to code designs we already knew how to create. This efficiency could stem from reduced code or hacks, or improved readability due to the new features.

In that spirit, let’s revamp what’s under the hood of a bar chart.

<ul class="chart" tabindex="0" role="list" aria-labelledby="chart-title"> <li class="chart-bar" data-value="32" tabindex="0" role="img" aria-label="32 percentage">32%</li> <!-- etc. --> </ul>

We begin by laying out a grid.

.chart { display: grid; grid-template-rows: repeat(100, 1fr); /* etc. */ }

The chart metric is based on percentage, as in “some number out of 100.” Let’s say we’re working with a grid containing 100 rows. That ought to stress test it, right?

Next, we add the bars to the grid with the grid-column and grid-row properties:

.chart-bar { grid-column: sibling-index(); grid-row: span attr(data-value number); /* etc. */ }

Right off the bat, I want to note a couple of things. First is that sibling-index() function. It’s brand new and has incomplete browser support as of this writing (come on, Firefox!), though it’s currently supported in the latest Chrome and Safari (but not on iOS apparently). Second is that attr() function. We’ve had it for a while, but it was recently upgraded and now accepts data-attributes. So when we have one of those in our markup — like data-value="32" — that’s something the function can read.

With those in place, that’s really all we need to create a pretty darn nice bar chart in vanilla CSS! The following demo has fallbacks in place so that you can still see the final result in case your browser hasn’t adopted those new features:

CodePen Embed Fallback

Yes, that was easy to do, but it’s best to know exactly why it works. So, let’s break that down.

Automatically Establishing Grid Columns

Declaring the sibling-index() function on the grid-column property explicitly places the list items in consecutive columns. I say “explicit” because we’re telling the grid exactly where to place each item by its data-value attribute in the markup. It goes first <li> in first column, second <li> in second column, and so forth.

That’s the power of sibling-index() — the grid intelligently generates the order for us without having to do it manually through CSS variables.

/* First bar: sibling-index() = 1 */ grid-column: sibling-index(); /* ...results in: */ grid-column: 1; grid-column-start: 1; grid-column-end: auto; /* Second bar: sibling-index() = 2 */ grid-column: sibling-index(); /* ...results in: */ grid-column: 2; grid-column-start: 2; grid-column-end: auto; /* etc. */ Automatically Establishing Grid Rows

It’s pretty much the same thing! But in this case, each bar occupies a certain number of rows based on the percentage it represents. The grid gets those values from the data-value attribute in the markup, effectively telling the grid how tall each bar in the chart should be.

/* First bar: data-value="32" */ grid-row: span attr(data-value number); /* ...results in: */ grid-row: span 32 /* Second bar: data-value="46" */ grid-row: span attr(data-value number); /* ...results in: */ grid-row: span 46

The attr() function, when provided with a data type parameter (the parameter value number in our case), casts the value retrieved by attr() into that specific type. In our example, the attr() function returns the value of data-value as a <number> type, which is then used to determine the number of rows to span for each bar.

Let’s Make Different Charts!

Since we have the nuts and bolts down on this approach, I figured I’d push things a bit and demonstrate how we can apply the same techniques for all kinds of CSS-only charts.

For example, we can use grid-row values to adjust the vertical direction of the bars:

CodePen Embed Fallback

Or we can skip bars altogether and use markers instead:

CodePen Embed Fallback CodePen Embed Fallback

We can also swap the columns and rows for horizontal bar charts:

CodePen Embed Fallback Wrapping up

Pretty exciting, right? Just look at all the ways we used to pull this stuff off before the days of sibling-index() and an upgraded attr():

CSS Bar Charts Using Modern Functions originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

No Hassle Visual Code Theming: Publishing an Extension

Tue, 02/03/2026 - 5:54am

Creating your theme is the fun part. After you’re done, the next step is to publish your theme so you — and others — can enjoy your creation!

You’d think that publishing a VS Code extension is an easy process, but it’s not. (Maybe I’m used to the ease of publishing npm packages and take registries for granted.)

Anyway, you have to publish your theme in two places:

  1. Visual Studio Marketplace for VS Code users
  2. Open VSX for other text editors

You might also want to publish to npm for others to use your theme easily for other contexts — like syntax highlighting via Shiki.

Preparing your theme

When you name your theme, you cannot put it under a scope like @scope/theme-name. Doing so will prevent you from publishing to Open VSX.

So, make sure your theme name is unscoped. (The theme word is optional):

{ "name": "twilight-cosmos-theme", }

To include an icon for your theme, you need a 128px square image file that can be accessible within your project. Put this under the icon property to point to the file:

{ "icon": "path/to/icon.png", }

Next, you want to ensure that you have a contributes key in your package.json file. VS Code and other text editors search for this to find themes.

{ "contributes": { "themes": [ { "label": "<Theme Name>", "uiTheme": "vs-dark", "path": "./<path-to-theme>.json" } ] }, }

Finally, you want to include several keywords to make your theme searchable on both VS Marketplace and Open VSX.

If you’re having problems with this, give AI your theme file and ask it to generate keywords for you &#x1f609;

{ "keywords": [ "theme", "dark theme", "twilight", "cosmos", "color-theme", "dark", "purple", "blue", "vscode-theme" ], } Publishing to Visual Studio Marketplace

Microsoft lets you publish to Visual Studio Marketplace via vsce if you have a personal access token from an Azure DevOps account.

Unfortunately, while creating this article, I encountered several problems setting up my Azure Devops account so I had to publish my extension via the manual route.

I’ll talk about both routes here.

Before publishing, you need to have a Visual Studio Marketplace account. So, sign up for one if you don’t have it yet.

Then do the following:

  • Click on Publish Extension.
  • Create a publisher account.

This step is needed for publishing both via vsce and the manual route.

Publishing via VSCE

For this to work, you need a Azure DevOps account. When you have that, you can create a Personal Access Token with these steps.

Note: It’s kinda irritating that you can’t have an lifetime access token with Azure DevOps. The maximum expiry is about one year later.

Also note: I had immense trouble creating my Azure DevOps account when I tried this — the back end kept hanging and I couldn’t find the right page, even when I copy-pasted the URL! Anyway, don’t be alarmed if this happens to you. You might just need to wait 1-2 days before you try again. It will work, eventually.

Once you have the personal access token, the rest of the steps is pretty straightforward.

First, you login to VSCE with your publisher ID that you created in Visual Studio Marketplace. (Insert the publisher ID, not the user ID!).

npx vsce login <publisher_id>

You’ll have to insert the access token when it asks you to. Then, run the next command to publish to the marketplace:

npx vsce publish

And you’re done!

Publishing manually

You’ll have to follow this route if you had problems with the personal access token like I did. Thankfully, it’s pretty straightforward as well. You can go to Visual Studio Marketplace and do the following:

  • Click on Publish Extensions.
  • Click New Extension.
  • Use the vsce package command to package your extension as a visx file.
  • Drag and drop the packaged visx file to upload your extension.

That’s it!

Getting verified on Visual Studio Code

If this is your first extension, you can only get “verified” on the Visual Studio Marketplace if your extension is at least six months old. So, if you want to get verified, set a reminder in six months and visit this page for more information.

Publishing to Open VSX

Thanks to Claude, I understood VS Code uses the Visual Studio Marketplace, but other text editors, like Cursor, use Open VSX.

Publishing to Open VSX is a bit more complex. You have to:

  • Login to Open VSX via GitHub.
  • Create an Eclipse Foundation account
  • Link your GitHub repository to the Eclipse Foundation account.
  • Sign their agreement.
  • Create a publisher namespace and add this as the publisher in your package.json file.
  • Create an access token.
  • Then, finally, run npx ovsx publish to publish your package.

Likewise, ovsx will ask you for a personal access token when you try to publish for the first time. Thankfully, ovsx seems to have a lifetime access token seems so we don’t have to worry about it expiring.

Claiming the publisher namespace

This is essentially getting “verified” with Open VSX, but Open VSX calls it “claiming” the publisher namespace to get verified. Without harping on the language too much — this process takes a bit of to-and-fro but can be done now (instead of six months later).

Once you have created a publisher namespace, you’ll see a glaring warning sign:

To claim the publisher namespace, you need to create a GitHub issue with Eclipse Foundation and state that you want to claim the namespace.

In that issue:

  • Include your GitHub repository (if you make it publicly available).
  • Offer to give access temporarily to your GitHub repository (if it’s private).

And someone will handle the rest.

The team at Eclipse Foundation seems to be pretty responsive, so I wouldn’t worry about communication breakdown here.

Including images for your theme

It makes sense to include images to showcase your theme in the Readme.md file. Doing so allows users to get a sense of your theme colors before deciding whether they want to download it.

Unfortunately, both VS Marketplace and Open VSX do not allow you to use relative URLs — images will be broken if you use relative links from your repository — so you have to link to an absolute URL instead.

The best place to link to is the GitHub repository, as long as it is set to public access.

The URL will be something like this:

![Alt Text](https://raw.githubusercontent.com/<github_username>/<repo-name>/master/<path-to-image>) Wrapping up

It can be tedious to publish your first VS Code editor theme. But don’t let that process stop you from letting you — and others – enjoy your theme!

If you’re wondering, my first theme is called Twilight Cosmos. You can find out more about the creation process in my previous article.

Enjoy the (somewhat frustrating) process! You’ll finish it before you know it.

No Hassle Visual Code Theming: Publishing an Extension originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

No-Hassle Visual Studio Code Theming: Building an Extension

Mon, 02/02/2026 - 4:51am

Years ago, when I read Sarah Drasner’s article on creating a VS Code theme, I silently thought to myself, That’s a lot of work… I’m never going to make a theme…

But lo and behold, I went ahead and made one — and it took less than six hours to get most of the theme working, then a day or two to polish up my final tweaks.

In this article, I want to you walk you through my process of creating this theme — along with the actual steps I took to create it.

I think talking about the process is powerful because I went from Nah, too much work to Oh, I can do it to It’s done..? all within a matter of hours. (The rest is simply time spent polishing).

I never wanted to make a VS Code theme…

I was in the middle of redesigning my website. I’ve been rocking a super duper old design that I’ve wanted to change for years — and I finally started moving.

I used Dracula Theme for code snippets in my old design and it worked since Dracula was the only thing that provided a splash of color in my otherwise stark design.

But it didn’t work well with my new site design.

All I wanted to do was to improve syntax highlighting for the code blocks so they’re more aligned with the rest of the site.

That was the beginning of everything.

Shiki CSS variable theming made it simple

I use Astro for my website. Shiki is a syntax highlighter that is built into Astro by default.

With some quick research, I realized Shiki allows you to create themes with CSS variables — and there are only a handful of colors we need to choose.

That doesn’t sound too complicated, so I got AI to help flesh out a Shiki theme based on the CSS variables. Here’s the CSS and JavaScript you need if you’re using Astro as well:

:root { --shiki-foreground: #eeeeee; --shiki-background: #333333; --shiki-token-constant: #660000; --shiki-token-string: #770000; --shiki-token-comment: #880000; --shiki-token-keyword: #990000; --shiki-token-parameter: #aa0000; --shiki-token-function: #bb0000; --shiki-token-string-expression: #cc0000; --shiki-token-punctuation: #dd0000; --shiki-token-link: #ee0000; } pre.shiki, pre.astro-code { padding: 1rem; border-radius: 0.5rem; color: var(--shiki-foreground); background-color: var(--shiki-background); overflow-x: auto; } pre.shiki code, pre.astro-code code { padding: 0; font-size: inherit; line-height: inherit; color: inherit; background: none; } import { createCssVariablesTheme } from 'shiki/core' const shikiVariableTheme = createCssVariablesTheme({ name: 'css-variables', variablePrefix: '--shiki-', fontStyle: true, }) export default defineConfig ({ // ... markdown: { shikiConfig: { theme: shikiVariableTheme } } })

I did a quick experiment with the colors I had already used for my website and compared it to various popular themes, like Dracula, Sarah’s Night Owl, and Moonlight 2.

This gave me the confidence to push my own theme a little further — because the syntax highlighting was shaping up in the right direction.

But, to push this further, I had to ditch CSS variable theming and dive into TextMate tokens. It was essential because certain code blocks looked absolutely horrendous and TextMate tokens provide more granular control of how and what gets color.

This is where the “hard” part begins.

Getting AI to help with TextMate scopes

Thankfully, AI is here to help. If AI wasn’t here, I might have just given up at this point.

Here’s what I got my AI to do:

  1. I said I wanted to make a custom theme.
  2. I told it to create a scaffold for me.
  3. I asked it to look for Moonlight 2’s theme files as a reference and create the TextMate scope tokens based on that.

I got it to consolidate the colors used into semantic keywords like foreground, background, keyword — like the Shiki CSS variable theme.

And I asked it to pull all of the colors into a color object so I can have a palette object that includes only the semantic names.

Here’s roughly what it created:

const colors = { purple: '...', blue: '...', // ... } const palette = { foreground: '...', background: '...', // ... } export default { colors: { // Used for theming the text editor }, displayName: 'Display Name of your Theme', name: 'your-theme-name', tokenColors: [ { name: 'Scope name (optional)', scope: [/*scopes used*/], settings: { foreground: /* change color */, background: /* background of the text */, fontStyle: /* normal, bold or italic */, } } ] }

You need to provide JSON for VS Code to configure things, so I also got AI to create a build script that converts the above format into a .json file.

You can find the build script and everything I used in the GitHub Repo.

Debugging locally

It was impossible to debug syntax highlighting on my website because I had to manually restart the server whenever I changed a variable.

So, I asked AI for a suggestion.

It said that I can use VS Code’s Extension Host for local development, then proceeded to created a .vscode/launch.json file with the following contents:

{ "version": "0.2.0", "configurations": [ { "name": "Extension", "type": "extensionHost", "request": "launch", "args": [ "--extensionDevelopmentPath=${workspaceFolder}" ] } ] }

To run this, you can use F5 (Windows) or Fn + F5 (Mac) and a new editor window will pop up — in this new window, you can change the theme to your custom theme.

Spotting a window that uses the extension host is quite simple because:

  • If you change your theme, that window will be a different theme compared to your other opened text editors.
  • The Extension Host keyword is prominent in the title.

Now, everything has been a blur at this point, so I can’t remember if you need to include the following into your package.json file for theme switching to work in the extension host. If so, include it:

{ "contributes": { "themes": [ { "label": "Your Theme Name", "uiTheme": "vs-dark", "path": "<path-to-your-theme>.json" } ] } } Understanding TextMate scopes

At first, I copy-pasted images and tried to get AI to adjust various tokens to the colors I chose. But it got frustrating quite quickly.

Either:

  • the AI got the textmate scope wrong, or
  • it was overwritten by something else.

I couldn’t tell. But thankfully you can debug the TextMate scopes easily with a “Developer: Inspector Editor Tokens and Scopes” command.

When you’re in this mode, you can click on any text and a window will pop up. This contains all the information you need to adjust TextMate scopes.

Here’s how to read what’s going on:

  • Foreground: Tells you the current active scope. In this case, the active scope is variable.
  • TextMate scopes: Tells you what are the available TextMate scopes you can use for this specific token.

TextMate scopes work in an interesting way. I figured out the following by experimenting, so it might not be 100% accurate:

  1. You can use any part of the available scopes. variable, variable.prop, and variable.prop.css all work.
  2. You can increase specificity by stating more properties. variable.prop.css > variable.prop > variable in terms of specificity.
  3. The higher scope is more specific than the lower one. variable > meta.function.misc.css.
  4. You can other scopes with them like CSS selectors if you need to overwrite a higher scope. meta.function variable > variable
How I chose colors for the theme

This is the most important topic when creating a theme. There’s no point having the theme if syntax highlighting doesn’t support the developer in reading code.

Two articles come into my mind here:

Essentially, the principles that I took away from both articles are:

  • We want highlights to stand out.
  • Colors will look very similar to each other if you make use the same lightness and chroma, and it’ll be hard to tell them apart.
  • If everything is highlighted, nothing is highlighted.
  • If everything is important, nothing is.

Basically, we’re talking about the principle of contrast when designing. Since I’m already designing for someone to read, the very next thoughts that came were:

  1. How do I guide my eyes?
  2. What are important elements that I have to see/know?
  3. What elements are less important?

With that, I began working:

  • Functions and methods were important so they had to be strong, so I used cyan which is the strongest color in my palette.
  • The export keyword is also important since it signifies an export!
  • Keywords like import and function can be rather muted, so purple it is.
  • Strings can be green — cos they seem rather pleasing in a list of text within a JSON file.
If text wasn’t green…this might be hard to look at.

I played around with the rest of the colors a little, but I eventually settled with the following:

  • Constants are orange because it’s kinda easy to spot them
  • Variables are white-ish because that’s the bulk of the text — adding colors to them creates the “Christmas Lights Diarrhea” effect Tonsky mentioned.
  • Properties are blue because they’re like workhorses that needs color differentiation, but not enough to draw too much attention.

Then I moved onto HTML/Astro/Svelte:

  • Tags are red because they’re kinda important — and red is easier to read that cyan.
  • Attributes are purple for the same reason as keywords.
  • Components are orange because they need to be different from Tags.
  • Bonus points: Tags and Components are related — so red and orange feels just right here.

And, finally, CSS syntax highlighting. Almost everything seemed right at this point, except that:

  • CSS Functions should be cyan like that in JS.
  • Punctuation should be muted so we can easily differentiate the -- from the rest of the text.
  • Property can be green because blue is too dull in this context — and green is nice on the eyes when contrasted with other powerful colors.

It’s a pity that syntax highlighting for nested classes goes a little bit haywire (they’re green, but they should be orange), but there’s nothing much I can do about it.

Debugging colors

VS Code is built on Electron, so it’s easy to debug and test colors. What I had to do was fire up devtools, inspect the color I wanted to change, and change them directly to get a live update!

Wrapping up

The most important I thing I learned during this process is to go with the flow. One opening can lead to another, then another, and something what seems “impossible” can become “Oh, it’s done?” in a matter of hours.

I call my theme Twilight Cosmos (AI helped with the naming). You can find it on:

How did I publish my extension? That’s the subject of a brief follow-up article that I’m working on.

In the meantime, here’s the GitHub repo if you want to build upon whatever I have done. Feel free to suggest edits to improve this theme too!

Finally, sign up for my email newsletter if you’re interested in hearing my creation adventures. :)

That’s it. Thanks for reading and I hope you had a blast!

No-Hassle Visual Studio Code Theming: Building an Extension originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What’s !important #4: Videos & View Transitions, Named Media Queries, How Browsers Work, and More

Fri, 01/30/2026 - 5:00am

Neither Chrome, Safari, nor Firefox have shipped new features in the last couple of weeks, but fear not because leading this issue of What’s !important is some of the web development industry’s best educators with, frankly, some killer content.

Maintaining video state across different pages using view transitions

Chris Coyier demonstrates how to maintain a video’s state across different pages using CSS view transitions. He notes that this is fairly easy to do with same-page view transitions, but with multi-page view transitions you’ll need to leverage JavaScript’s pageswap event to save information about the video’s state in sessionStorage as a JSON string (works with audio and iframes too), and then use that information to restore the state on pagereveal. Yes, there’s a tiiiiny bit of audio stutter because we’re technically faking it, but it’s still super neat.

Also, CodePen, which I’m sure you already know was founded by Chris, announced a private beta of CodePen 2.0, which you can request to be a part of. One of the benefits of CodePen 2.0 is that you can create actual projects with multiple files, which means that you can create view transitions in CodePen. Pretty cool!

How to ‘name’ media queries

Kevin Powell shows us how to leverage CSS cascade layers to ‘name’ media queries. This technique isn’t as effective as @custom-media (or even container style queries, as one commenter suggested), but until those are supported in all web browsers, Kevin’s trick is pretty creative.

Adam Argyle reminded us last week that @custom-media is being trialed in Firefox Nightly (no word on container style queries yet), but if you get up to speed on CSS cascade layers, you can utilize Kevin’s trick in the meantime.

Vale’s CSS reset

I do love a good CSS reset. It doesn’t matter how many of them I read, I always discover something awesome and add it to my own reset. From Vale’s CSS reset I stole svg:not([fill]) { fill: currentColor; }, but there’s much more to take away from it than that!

How browsers work

If you’ve ever wondered how web browsers actually work — how they get IP addresses, make HTTP requests, parse HTML, build DOM trees, render layouts, and paint, the recently-shipped How Browsers Work by Dmytro Krasun is an incredibly interesting, interactive read. It really makes you wonder about the bottlenecks of web development languages and why certain HTML, CSS, and JavaScript features are the way they are.

How CSS layout works

In addition, Polypane explains the fundamentals of CSS layout, including the box model, lines and baselines, positioning schemes, the stacking context, grid layout, and flexbox. If you’re new to CSS, I think these explanations will really help you click with it. If you’re an old-timer (like me), I still think it’s important to learn how these foundational concepts apply to newer CSS features, especially since CSS is evolving exponentially these days.

CSS masonry is (probably) just around the corner

Speaking of layouts, Jen Simmons clarifies when we’ll be able to use display: grid-lanes, otherwise known as CSS masonry. While it’s not supported in any web browser yet, Firefox, Safari, and Chrome/Edge are all trialing it, so that could change pretty quickly. Jen provides some polyfills, anyway!

If you want to get ahead of the curve, you can let Sunkanmi Fafowora walk you through display: grid-lanes.

Source: Webkit. Theming animations using relative color syntax

If you’re obsessed with design systems and organization, and you tend to think of illustration and animation as impressive but messy art forms, Andy Clarke’s article on theming animations using CSS relative color syntax will truly help you to bridge the gap between art and logic. If CSS variables are your jam, then this article is definitely for you.

Modals vs. pages (and everything in-between)

Modals? Pages? Lightboxes? Dialogs? Tooltips? Understanding the different types of overlays and knowing when to use each one is still pretty confusing, especially since newer CSS features like popovers and interest invokers, while incredibly useful, are making the landscape more cloudy. In short, Ryan Neufeld clears up the whole modal vs. page thing and even provides a framework for deciding which type of overlay to use.

Source: UX Planet Text scaling support is being trialed in Chrome Canary

You know when you’re dealing with text that’s been increased or decreased at the OS-level? Well…if you’re a web developer, maybe you don’t. After all, this feature doesn’t work on the web! However, Josh Tumath tells us that Chrome Canary is trialing a meta tag that makes web browsers respect this OS setting. If you’re curious, it’s <meta name="text-scale" content="scale">, but Josh goes into more detail and it’s worth a read.

See you next time!

What’s !important #4: Videos & View Transitions, Named Media Queries, How Browsers Work, and More originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Styling ::search-text and Other Highlight-y Pseudo-Elements

Wed, 01/28/2026 - 5:08am

Chrome 144 recently shipped ::search-text, which is now one of several highlight-related pseudo-elements. This one selects find-in-page text, which is the text that gets highlighted when you do a Ctrl/Command + F-type search for something on a page and matches are found.

By default, ::search-text matches are yellow while the current target (::search-text:current) is orange, but ::search-text enables us to change that.

I’ll admit, I hadn’t really been following these highlight pseudo-elements. Up until now, I didn’t even know that there was a name for them, but I’m glad there is because that makes it easier to round them all up and compare them, which is exactly what I’m going to do here today, as it’s not super obvious what they do based on the name of the pseudo-element. I’ll also explain why we’re able to customize them, and suggest how.

The different types of highlight pseudo-elements Pseudo-selectorSelects…Notes::search-textFind-in-page matches::search-text:current selects the current target::target-textText fragmentsText fragments allow for programmatic highlighting using URL parameters. If you’re referred to a website by a search engine, it might use text fragments, which is why ::target-text is easily confused with ::search-text.::selectionText highlighted using the pointer::highlight()Custom highlights as defined by JavaScript’s Custom Highlight API::spelling-errorIncorrectly spelled wordsPretty much applies to editable content only::grammar-errorIncorrect grammarPretty much applies to editable content only

And let’s not forget about the <mark> HTML element either, which is what I’m using in the demos below.

What should highlight pseudo-elements look like?

The question is, if they all (besides ::highlight()) have default styling, why would we need to select them with pseudo-elements? The reason is accessibility (color contrast, specifically) and usability (emphasis). For example, if the default yellow background of ::search-text doesn’t contrast well enough with the text color, or if it doesn’t stand out against the background of the container, then you’ll want to change that.

I’m sure there are many ways to solve this (I want to hear “challenge accepted” in the comments), but the best solution that I’ve come up with uses relative color syntax. I took wrong turns with both background-clip: text and backdrop-filter: invert(1) before realizing that many CSS properties are off-limits when it comes to highlight pseudo-elements:

body { --background: #38003c; background: var(--background); mark, ::selection, ::target-text, ::search-text { /* Match color to background */ color: var(--background); /* Convert to RGB then subtract channel value from channel maximum (255) */ background: rgb(from var(--background) calc(255 - r) calc(255 - g) calc(255 - b)); } } CodePen Embed Fallback

Your browser might not support that yet, so here’s a video that shows how the highlighted text adapts to background color changes.

What’s happening here is that I’m converting the container’s background color to RGB format and then subtracting the value of each channel (r, g, and b) from the maximum channel value of 255, inverting each channel and the overall color. This color is then set as the background color of the highlighting, ensuring that it stands out no matter what, and thanks to the new CodePen slideVars, you can mess around with the demo to see this in action. You might be able to do this with color formats besides RGB, but RGB is the easiest.

So that covers the usability, but what about the accessibility?

Well, the highlighting’s text color is the same as the container’s background color because we know that it’s the inverse of the highlighting’s background color. While this doesn’t mean that the two colors will have accessible contrast, it seems as though they will most of the time (you should always check color contrast using color contrast tools, regardless).

If you don’t like the randomness of inverting colors, that’s understandable. You can totally pick colors and write conditional CSS for them manually instead, but finding accessible colors that stand out against the different backdrops of your design for all of the different types of highlight pseudo-elements, while accounting for alternative viewing modes such as dark mode, is a headache. Besides, I think certain UI elements (e.g., highlights, errors, focus indicators) should be ugly. They should stand out in a brutalist sort of way and feel disconnected from the design’s color palette. They should demand maximum attention by intentionally not fitting in.

Keep in mind that the different types of highlight pseudo-elements should be visually distinctive too, for obvious reasons, but also in case two different types overlap each other (e.g., the user selects text currently matched by find-in-page). Therefore, in the amended code snippet below, mark, ::selection, ::target-text, and ::search-text all have slightly different backgrounds.

I’ve left mark unchanged, the r value of ::selection as it was, the g value of ::target-text as it was, and the b value of ::search-text as it was, so those last three only have two channels inverted instead of all three. They’re varied in color now (but still look inverted), and with the addition of an alpha value at 70% (100% for ::search-text:current), they also blend into each other so that we can see where each highlight begins and ends:

body { --background: #38003c; background: var(--background); mark, ::selection, ::target-text, ::search-text { color: var(--background); } mark { /* Invert all channels */ background: rgb(from var(--background) calc(255 - r) calc(255 - g) calc(255 - b) / 70%); } ::selection { /* Invert all channels but R */ background: rgb(from var(--background) r calc(255 - g) calc(255 - b) / 70%); } ::target-text { /* Invert all channels but G */ background: rgb(from var(--background) calc(255 - r) g calc(255 - b) / 70%); } ::search-text { /* Invert all channels but B */ background: rgb(from var(--background) calc(255 - r) calc(255 - g) b / 70%); &:current { /* Invert all channels but B, but without transparency */ background: rgb(from var(--background) calc(255 - r) calc(255 - g) b / 100%); } } } CodePen Embed Fallback

::spelling-error and ::grammar-error are excluded from all this because they have their own visual affordances (red underlines and green underlines respectively, typically contrasted against the neutral background of an editable element such as <textarea>).

But mark, ::selection, ::target-text, and new-to-Chrome ::search-text? Well, they can appear anywhere (even on top of each other), so I think it’s important that they’re visually distinctive from each other while being accessible at all times. Again though, even fully-inverted colors can be inaccessible. In fact, the inverse of #808080 is #808080, so test, test, test! Although, maybe contrast-color() could come to the rescue once the CSS Color Module Level 5 version of it ships.

In the meantime, please, no more highlight-y elements!

Styling ::search-text and Other Highlight-y Pseudo-Elements originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

ReliCSS

Wed, 01/28/2026 - 3:25am

We all have a few skeletons in our CSS closets. There’s probably that one-off !important where you can now manage that more effectively with cascade layers. Or maybe a dated Checkbox Hack that :has() has solved. Perhaps it’s been a long while since your last site redesign and it’s chock-full of vendor-prefixed properties from 2012. Thar be demons!

Stu Robson’s ReliCSS (clever name!) tool can excavate outdated CSS in your codebase that have modern CSS solutions.

Each relic is assigned a level of severity. As Stu explains it:

  • High Severity: True “fossils”. Hacks for (now) unsupported browsers (IE6/7) or “dangerous” techniques. High-risk, obsolete, should be first targets for removal.
  • Medium Severity: The middle ground. Hacks for older unsupported browsers (IE8-10). They work but they’re fragile. Hacks to review to see if they’re still relevant for your actual users.
  • Low Severity: Modern artifacts. Usually vendor prefixes (-webkit-, -moz-). Safe mostly, but better handled by automated tools like Autoprefixer. They’re an opportunity to improve your build process.

It’s been a little while since my personal site got an overhaul. Not to toot my own horn, but heyyyyyy!

Seriously, though. I know there are things in there I’m embarrassed to admit.

But what if we do archeological dig on CSS-Tricks? I mean, it’s been at least five years since this place has gotten the love it deserves. I’m almost afraid to look. Here goes…

&#x1fae3;

OK, not as bad as I imagined. It’s largely vendor prefixing, which I’m sure comes courtesy of an older Autoprefixer configuration.

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

There is No Need to Trap Focus on a Dialog Element

Mon, 01/26/2026 - 5:02am

I was building a Modal component that uses the <dialog> element’s showModal method. While testing the component, I discovered I could tab out of the <dialog> (in modal mode) and onto the address bar.

And I was surprised — accessibility advice around modals have commonly taught us to trap focus within the modal. So this seems wrong to me.

Upon further research, it seems like we no longer need to trap focus within the <dialog> (even in modal mode). So, the focus-trapping is deprecated advice if you use <dialog>.

Some notes for you

Instead of asking you to read through the entire GitHub Issue detailing the discussion, I summarized a couple of key points from notable people below.

Here are some comments from Scott O’Hara that tells us about the history and context of the focus-trapping advice:

WCAG is not normatively stating focus must be trapped within a dialog. Rather, the normative WCAG spec makes zero mention of requirements for focus behavior in a dialog.

The informative 2.4.3 focus order understanding doc does talk about limiting focus behavior within a dialog – but again, this is in the context of a scripted custom dialog and was written long before inert or <dialog> were widely available.

The purpose of the APG is to demonstrate how to use ARIA. And, without using native HTML features like <dialog> or inert, it is far easier to trap focus within the custom dialog than it is to achieve the behavior that the <dialog> element has.

Both the APG modal dialog and the WCAG understanding doc were written long before the inert attribute or the <dialog> element were widely supported. And, the alternative to instructing developers to trap focus in the dialog would have been to tell them that they needed to ensure that all focusable elements in the web page, outside of the modal dialog, received a tabindex=-1.

Léonie Watson weighs in and explains why it’s okay for a screen-reader user to move focus to the address bar:

In the page context you can choose to Tab out of the bottom and around the browser chrome, you can use a keyboard command to move straight to the address bar or open a particular menu, you can close the tab, and so on. This gives people a choice about how, why, and what they do to escape out of the context.

It seems logical (to me at least) for the same options to be available to people when in a dialog context instead of a page context.

Finally, Matatk shared the conclusion from the W3C’s Accessible Platform Architectures (APA) Working Group that okay-ed the notion that <dialog>‘s showModal method doesn’t need to trap focus.

We addressed this question in the course of several APA meetings and came to the conclusion that the current behavior of the native dialog element should be kept as it is. So, that you can tab from the dialog to the browser functionalities.

We see especially the benefit that keyboard users can, for example, open a new tab to look something important up or to change a browser setting this way. At the same time, the dialog element thus provides an additional natural escape mechanism (i.e. moving to the address bar) in, for example, kiosk situations where the user cannot use other standard keyboard shortcuts.

From what I’m reading, it sounds like we don’t have to worry about focus trapping if we’re properly using the Dialog API’s showModal method!

Hope this news make it easier for you to build components. &#x1f609;

There is No Need to Trap Focus on a Dialog Element originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Responsive Hexagon Grid Using Modern CSS

Fri, 01/23/2026 - 4:39am

Five years ago I published an article on how to create a responsive grid of hexagon shapes. It was the only technique that didn’t require media queries or JavaScript. It works with any number of items, allowing you to easily control the size and gap using CSS variables.

CodePen Embed Fallback

I am using float, inline-block, setting font-size equal to 0, etc. In 2026, this may sound a bit hacky and outdated. Not really since this method works fine and is well supported, but can we do better using modern features? In five years, many things have changed and we can improve the above implementation and make it less hacky!

Support is limited to Chrome only because this technique uses recently released features, including corner-shape, sibling-index(), and unit division.

CodePen Embed Fallback

The CSS code is shorter and contains fewer magic numbers than the last time I approached this. You will also find some complex calculations that we will dissect together.

Before diving into this new demo, I highly recommend reading my previous article first. It’s not mandatory, but it allows you to compare both methods and realize how much (and rapidly) CSS has evolved in the last five years by introducing new features that make one-difficult things like this easier.

The Hexagon Shape

Let’s start with the hexagon shape, which is the main element of our grid. Previously, I had to rely on clip-path: polygon() to create it:

.hexagon { --s: 100px; width: var(--s); height: calc(var(--s) * 1.1547); clip-path: polygon(0% 25%, 0% 75%, 50% 100%, 100% 75%, 100% 25%, 50% 0%); }

But now, we can rely on the new corner-shape property which works alongside the border-radius property:

.hexagon { width: 100px; aspect-ratio: cos(30deg); border-radius: 50% / 25%; corner-shape: bevel; }

Simpler than how we used to bevel elements, and as a bonus, we can add a border to the shape without workarounds!

CodePen Embed Fallback

The corner-shape property is the first modern feature we are relying on. It makes drawing CSS shapes a lot easier than traditional methods, like using clip-path. You can still keep using the clip-path method, of course, for better support (and if you don’t need a border on the element), but here is a more modern implementation:

.hexagon { width: 100px; aspect-ratio: cos(30deg); clip-path: polygon(-50% 50%,50% 100%,150% 50%,50% 0); } CodePen Embed Fallback

There are fewer points inside the polygon, and we replaced the magic number 1.1547 with an aspect-ratio declaration. I won’t spend more time on the code of the shapes, but here are two articles I wrote if you want a detailed explanation with more examples:

The Responsive Grid

Now that we have our shape, let’s create the grid. It’s called a “grid,” but I am going to use a flexbox configuration:

<div class="container"> <div></div> <div></div> <div></div> <div></div> <!-- etc. --> </div> .container { --s: 120px; /* size */ --g: 10px; /* gap */ display: flex; gap: var(--g); flex-wrap: wrap; } .container > * { width: var(--s); aspect-ratio: cos(30deg); border-radius: 50% / 25%; corner-shape: bevel; } CodePen Embed Fallback

Nothing fancy so far. From there, we add a bottom margin to all items to create an overlap between the rows:

.container > * { margin-bottom: calc(var(--s)/(-4*cos(30deg))); } CodePen Embed Fallback

The last step is to add a left margin to the first item of the even rows (i.e., 2nd, 4th, 6th, and so). This margin will create the shift between rows to achieve a perfect grid.

Said like that, it sounds easy, but it’s the trickiest part where we need complex calculations. The grid is responsive, so the “first” item we are looking for can be any item depending, on the container size, item size, gap, etc.

Let’s start with a figure:

Our grid can have two aspects depending on the responsiveness. We can either have the same number of items in all the rows (Grid 1 in the figure above) or a difference of one item between two consecutive rows (Grid 2). The N and M variables represent the number of items in the rows. In Grid 1 we have N = M, and in Grid 2 we have M = N - 1.

In Grid 1, the items with a left margin are 6, 16, 26, etc., and in Grid 2, they are 7, 18, 29, etc. Let’s try to identify the logic behind those numbers.

The first item in both grids (6 or 7) is the first one in the second row, so it’s the item N + 1. The second item (16 or 18) is the first one in the third row, so it’s the item N + M + N + 1. The third item (26 or 29) is the item N + M + N + M + N + 1. If you look closely, you can see a pattern that we can express using the following formula:

N*i + M*(i - 1) + 1

…where i is a positive integer (zero excluded). The items we are looking for can be found using the following pseudo-code:

for(i = 0; i< ?? ;i++) { index = N*i + M*(i - 1) + 1 Add margin to items[index] }

We don’t have loops in CSS, though, so we will have to do something different. We can obtain the index of each item using the new sibling-index() function. The logic is to test if that index respect the previous formula.

Instead of writing this:

index = N*i + M*(i - 1) + 1

…let’s express i using the index:

i = (index - 1 + M)/(N + M)

We know that i is a positive integer (zero excluded), so for each item, we get its index and test if (index - 1 + M)/(N + M) is a positive integer. Before that, let’s calculate the number of items, N and M.

Calculating the number of items per row is the same as calculating how many items can fit in that row.

N = round(down,container_size / item_size);

Dividing the container size by the item size gives us a number. If we round()` it down to the nearest integer, we get the number of items per row. But we have a gap between items, so we need to account for this in the formula:

N = round(down, (container_size + gap)/ (item_size + gap));

We do the same for M, but this time we need to also account for the left margin applied to the first item of the row:

M = round(down, (container_size + gap - margin_left)/ (item_size + gap));

Let’s take a closer look and identify the value of that margin in the next figure:

It’s equal to half the size of an item, plus half the gap:

M = round(down, (container_size + gap - (item_size + gap)/2)/(item_size + gap)); M = round(down, (container_size - (item_size - gap)/2)/(item_size + gap));

The item size and the gap are defined using the --s and --g variables, but what about the container size? We can rely on container query units and use 100cqw.

Let’s write what we have until now using CSS:

.container { --s: 120px; /* size */ --g: 10px; /* gap */ container-type: inline-size; /* we make it a container to use 100cqw */ } .container > * { --_n: round(down,(100cqw + var(--g))/(var(--s) + var(--g))); --_m: round(down,(100cqw - (var(--s) - var(--g))/2)/(var(--s) + var(--g))); --_i: calc((sibling-index() - 1 + var(--_m))/(var(--_n) + var(--_m))); margin-left: ???; /* We're getting there! */ }

We can use mod(var(--_i),1) to test if --_i is an integer. If it’s an integer, the result is equal to 0. Otherwise, it’s equal to a value between 0 and 1.

We can introduce another variable and use the new if() function!

.container { --s: 120px; /* size */ --g: 10px; /* gap */ container-type: inline-size; /* we make it a container to use 100cqw */ } .container > * { --_n: round(down,(100cqw + var(--g))/(var(--s) + var(--g))); --_m: round(down,(100cqw - (var(--s) - var(--g))/2)/(var(--s) + var(--g))); --_i: calc((sibling-index() - 1 + var(--_m))/(var(--_n) + var(--_m))); --_c: mod(var(--_i),1); margin-left: if(style(--_c: 0) calc((var(--s) + var(--g))/2) else 0;); }

Tada!

CodePen Embed Fallback

It’s important to note that you need to register the variable --_c variable using @property to be able to do the comparison (I write more about this in “How to correctly use if()in CSS).

This is a good use case for if(), but we can do it differently:

--_c: round(down, 1 - mod(var(--_i), 1));

The mod() function gives us a value between 0 and 1, where 0 is the value we want. -1*mod() gives us a value between -1 and 0. 1 - mod() gives us a value between 0 and 1, but this time it’s the 1 we need. We apply round() to the calculation, and the result will be either 0 or 1. The --_c variable is now a Boolean variable that we can use directly within a calculation.

margin-left: calc(var(--_c) * (var(--s) + var(--g))/2);

If --_c is equal to 1, we get a margin. Otherwise, the margin is equal to 0. This time you don’t need to register the variable using @property. I personally prefer this method as it requires less code, but the if() method is also interesting.

CodePen Embed Fallback

Should I remember all those formulas by heart?! It’s too much!

No, you don’t. I tried to provide a detailed explanation behind the math, but it’s not mandatory to understand it to work with the grid. All you have to do is update the variables that control the size and gap. No need to touch the part that set the left margin. We will even explore how the same code structure can work with more shapes!

More Examples

The common use case is a hexagon shape but what about other shapes? We can, for example, consider a rhombus and, for this, we simply adjust the code that controls the shape.

From this:

.container > * { aspect-ratio: cos(30deg); border-radius: 50% / 25%; corner-shape: bevel; margin-bottom: calc(var(--s)/(-4*cos(30deg))); }

…to this:

.container > * { aspect-ratio: 1; border-radius: 50%; corner-shape: bevel; margin-bottom: calc(var(--s)/-2); } CodePen Embed Fallback

A responsive grid of rhombus shapes — with no effort! Let’s try an octagon:

.container > * { aspect-ratio: 1; border-radius: calc(100%/(2 + sqrt(2))); corner-shape: bevel; margin-bottom: calc(var(--s)/(-1*(2 + sqrt(2)))); } CodePen Embed Fallback

Almost! For an octagon, we need to adjust the gap because we need more horizontal space between the items:

.container { --g: calc(10px + var(--s)/(sqrt(2) + 1)); gap: 10px var(--g); }

The variable --g includes a portion of the size var(--s)/(sqrt(2) + 1) and is applied as a row gap, while the column gap is kept the same (10px).

CodePen Embed Fallback

From there, we can also get another type of hexagon grid:

CodePen Embed Fallback

And why not a grid of circles as well? Here we go:

CodePen Embed Fallback

As you can see, we didn’t touch the complex calculation that sets the left margin in any of those examples. All we had to do was to play with the border-radius and aspect-ratio properties to control the shape and adjust the bottom margin to rectify the overlap. In some cases, we need to adjust the horizontal gap.

Conclusion

I will end this article with another demo that will serve as a small homework for you:

CodePen Embed Fallback

This time, the shift is applied to the odd rows rather than the even ones. I let you dissect the code as a small exercise. Try to identify the change I have made and what’s the logic behind it (Hint: try to redo the calculation steps using this new configuration.)

Responsive Hexagon Grid Using Modern CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Open Props @custom-media Recipes

Fri, 01/23/2026 - 3:00am

The @custom-media at-rule has landed in Firefox Nightly! I couldn’t find it in the release notes but Adam Argyle’s on the beat noting that it’s behind a flag for now.

Look for layout.css.custom-media.enabled

I often forget the exact name of an @media query or simply get tired writing something like @media screen and (prefers-reduced-motion: no-preference) over and over again. @custom-media will be a nice bit of relief to the ol’ muscle memory because it allows us to create aliases for queries.

In fact, Adam’s Open Props project has more than 45 of them that make for excellent recipes:

@custom-media --motionOK (prefers-reduced-motion: no-preference); @media (--motionOK) { /* animations and transitions */ }

Open Props @custom-media Recipes originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

HTTP Archive 2025 Web Almanac

Fri, 01/16/2026 - 7:30am

I love me some good web research reports. I’m a sucker for them. HTTP Archive’s Web Almanac is one report I look forward to every year, and I know I’m not alone there. It’s one of those highly-anticipated publications on the state of the web, chock-full of well-documented findings about millions of live websites — 17.2 million in this edition! — from page content, to performance, to accessibility, to UX, to… well, let’s just get to it.

It just came out, so there’s no way I’ve read through all 15 chapters, let alone digested and reflected on everything in it. Really, I just want you to be aware that it’s out. That said, it’s hard for me to resist sharing at least a few notable stats that hit me and that I’ll be sure to dig into.

Some highlights:

  • New text-wrap values are showing up! It’s small, but not surprising for features that only shipped as far back as 2023. Specifically, I’m looking at the balance (2.67%) and pretty (1.71%) values.
  • Variable fonts are no longer a novelty. “How popular are variable fonts? This year, 39.4% of desktop websites and 41.3% of mobile websites used at least one variable font on their pages. In other words, now about 4 in 10 sites are using variable fonts.”
  • Why can’t we nail down color contrast?! Only 30% of sites meet WCAG guidelines, and though that’s a number that’s trending up (21% in 2020), that’s a sorry stat.
  • Removing focus styles is an epidemic. A whopping 67% of sights remove focus outlines despite WCAG’s requirement that “Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.”
  • Many images are apparently decorative. At least, that’s what 30% of sites are suggesting by leaving the alt attribute empty. But if we consider that 14% of sites leave off the attribute completely, we’re looking at roughly 44% of sites that aren’t describing their visual content. On that note, your images probably are not decorative.
  • ARIA labels are everywhere. We’re looking at 70% usage (29% on buttons). This doesn’t mean anything in and of itself. It could be a good thing, but could also be an issue without proper usage.
  • The CMS landscape is largely unchanged. I mean, WordPress is still the dominant force, and that’s no dang surprise. At this point, its expansion wavers between a couple percentage points every year. “These changes suggest that WordPress is shifting from a focus on expansion to one on stabilization.” That’s a good thing.
  • Bloat, bloat, bloat. “In July 2015, the median mobile home page was a meager 845 KB. As of July 2025, the same median page is now 2,362 KB. The page decade brought a 202.8% increase.” In a perfect world where we’re all super conscious about page weight, I’d say we oughta aim for less than half that total.
  • JavaScript be heavy. Images are heaviest, of course, but 697 KB of JavaScript is a lot to stomach. That massive growth in page weight since 2015 is more support that this was a lost decade we must reckon with.

HTTP Archive 2025 Web Almanac originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

“I Heart CSS” DailyDev Squad

Fri, 01/16/2026 - 4:46am

If you’re reading this, chances are you already have some sort of way that you’re following when we publish new content, whether that’s RSS, Bluesky, Mastodon, or what have you. But I know a lot of folks like to use DailyDev as well and, if that’s you, we have a couple of ways you can get our stuff there as well. There’s our channel that automatically pulls in new content. There’s also a community page — what DailyDev calls a “squad” — where we curate our content as well as other interesting CSS-y links of interest, called I Heart CSS.

See you there?

“I Heart CSS” DailyDev Squad originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What’s !important #3: Popover Context Menus, @scope, New Web Platform Features, and More

Thu, 01/15/2026 - 5:08am

The developer community hasn’t wasted any time kicking off 2026 with some really great articles, demos, and insights. Firefox 147 and Chrome 144 also shipped, and while they’re not jam-packed with features, the releases are still pretty exciting for what’s normally a slow time of year, so without further ado, here’s what’s important from the last couple of weeks (or should I say the first couple of weeks, of 2026?)…

Building popover context menus with anchor positioning

Chris Coyier (a familiar name, perhaps) shows us how to build context menus using popovers and anchor positioning over at Frontend Masters. Interest invokers, <menu>, discrete transitions, @starting-style, and fallback positions are also mentioned, so grab a pickaxe, because this one’s a bit of a goldmine.

Also, anchor positioning went baseline this week, so you can use it on production websites now! Do we have our CSS feature of the year already?

Scoping CSS with @scope

Funnily enough, I also got the opportunity to write something for Frontend Masters, and I went with @scope. @scope has been my most-anticipated CSS feature for quite a while now, and Firefox shipping it in their final release of the year (making it baseline) made it my feature of the year, so I’m very happy to kick off 2026 with this little how-to on using @scope and scoping CSS overall.

Generating gradient borders from an image source

In this demo, created and posted by Ana Tudor on Bluesky, Ana blurs an image and masks it with a border. You can actually accomplish this in Safari using just three lines of CSS, but the cross-browser solution isn’t too complex either (the key parts are the backdrop-filter and mask CSS properties).

Given the current popularity of gradients, blurs, and dare I say it, glass, it’s a pretty sweet effect that you can probably adapt for other scenarios.

Offset gradient border from img source – how would you get the result from the screen below? Real gap transparency, border gradient obtained from the image. My solutions on @codepen.io: Safari only in 3 declarations codepen.io/thebabydino/… Cross-browser codepen.io/thebabydino/… #CSS #filter

[image or embed]

— Ana Tudor (@anatudor.bsky.social) 11 January 2026 at 09:52 You probably don’t need tabs

HTML, like CSS, is soooo good now. That being said, even though we’ve been getting all these new HTML elements that enable us to build interactive components without JavaScript, that doesn’t necessarily mean that we should. Stephen Margheim says that tab components are over-engineered most of the time, and explains why and what you can do instead.

A hot take after seeing yet another fancy tabs design: the classic "tab component" is over-engineered for 90% of use cases. You probably don't need it…

— Stephen Margheim (@fractaledmind.bsky.social) 3 January 2026 at 19:57 Using your OS as a CMS

Speaking of simplicity, Jim Nielsen introduced me to this incredibly cool OS-as-a-CMS concept as he explains how he got “Edit Post” buttons on his website to open the local document on his computer in iA Writer, completely negating the need for a CMS. Jim walks you through the whole thing, but the key ingredient is just a little link with a custom URL scheme:

<a href="ia-writer://open?path=posts:post.md">Edit</a>

I love this because I also write in Markdown (using iA Writer, no less), and could will easily integrate this into my Eleventy build. But it got me thinking: do any other apps have their own URL scheme? Well, as it turns out, some of them do! Here’s an incomplete list (with examples of ‘edit’ commands for each app):

  • Obsidian: obsidian://open?vault=posts&file=post
  • VS Code: vscode://exact/path/to/post.md:9:1 (:9:1 is the line and column number)
  • Ulysses: ulysses://x-callback-url/open-item?id=ITEM_ID
  • Sublime Text (with subl protocol): subl://exact/path/to/post.md:9:1
  • Apple Shortcuts: shortcuts://run-shortcut?name=Edit&input=/path/to/post.md (great for doing stuff with apps that don’t have custom URL schemes)
Quick hits and new web platform features

As you know (hopefully?), we post Quick Hits throughout the week. The best way to find them is in the sidebar of the homepage, and they’re either links to things that you can read in just a minute or two, or just PSAs to read and remember. Anyway, here’s what you might’ve missed:

Ready for the first cool demo of the year? A mini Mario world with keyboard control. Yes, you can move Mario and also jump! &#x1f440; Demo: codepen.io/t_afif/full/… via @codepen.io ✔️ 100% CSS Magic ✔️ Minimal HTML ❌ 0% JavaScript A Chrome-only experimentation using modern CSS.

[image or embed]

— CSS by T. Afif (@css-only.dev) 2 January 2026 at 13:39

And finally, here are my top picks from what Firefox and Chrome shipped on Tuesday:

Thanks for tuning in. I’ll see you in two weeks! Be there or be square (aspect-ratio: 1)!

What’s !important #3: Popover Context Menus, @scope, New Web Platform Features, and More originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Playing With CodePen slideVars

Wed, 01/14/2026 - 4:59am

Super cool new CodePen feature alert! You’ve probably seen a bunch of “interactive” demos that let you change values on the fly from a UI panel embedded directly in the demo. Jhey’s demos come immediately to mind, like this one:

CodePen Embed Fallback

That’s a tool called TweakPane doing the work. There’s another one called Knobs by Yair Even Or that Adam Argyle often uses:

CodePen Embed Fallback

I’ve often faked it with either the Checkbox Hack or a sprinkle of JavaScript when I’m demoing a very specific concept:

CodePen Embed Fallback

OK, enough examples because CodePen has a homegrown tool of its own called slideVars. All you have to do is import it and call it in the JavaScript panel:

import { slideVars } from "@codepen/slidevars"; slideVars.init();

You can import it into a project off CodePen if you’re so inclined.

That two-liner does a lot of lifting. It auto-detects CSS variables in your CSS and builds the panel for you, absolutely-positioned in the top-right corner:

CodePen Embed Fallback

It looks like you have to declare your variables on the :root element with default usage. I tried scoping them directly to the element and it was a no-go. It’s possible with a manual configuration, though.

CodePen Embed Fallback

Pretty cool, right? You can manually configure the input type, a value range, a default value, unit type, and yes, a scope that targets the element where the variables are defined. As far as units go, it supports all kinds of CSS numeric units. That includes unit-less values, though the documentation doesn’t explicitly say it. Just leave the unit property as an empty string ("").

I guess the only thing I’d like is to tell slideVars exactly what increments to use when manually configuring things. For example, unit-less values simply increment in integers, even if you define the default value as a decimal:

CodePen Embed Fallback

It works in default mode, however:

CodePen Embed Fallback

There’s a way to place the slideVars wherever you want by slapping a custom element where you want it in the HTML. It’s auto-placed at the bottom of the HTML <body> by default.

<slide-vars> <p>Custom Label!</p> </slide-vars>

Or CSS it by selecting the custom element:

CodePen Embed Fallback

So much fun!

CodePen Embed Fallback

Playing With CodePen slideVars originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Postcard From Web Directions Dev Summit, 2025

Mon, 01/12/2026 - 4:54am

Author’s Note: There are already wonderful recaps of the Web Directions Developer Summit I spoke at in November 2025. So, rather than offering another one, I decided to capture my experience at the conference in a stream-of-consciousness style that details my battles with stage fright and imposter syndrome. I haven’t seen this style used on a tech blog before, but CSS-Tricks has become my playground for experiments — not just with CSS, but with language itself — so let’s see where this experiment takes us.

Arrival

When I was a kid, there used to be a Museum railway station in Melbourne, Australia. In 1995, it changed its name to match the shopping center above it — a microcosm of how the mentality of my home city has shifted — but Sydney still has a Museum station. The aesthetics of Sydney’s Museum Station evoke London Underground vibes as my train from Sydney Airport stops under Hyde Park, the oldest public park in Australia and the first to be named after its more famous London counterpart.

Britain’s on my brain because I want this trip to resemble the Harry Potter stories: the wish-fulfillment narrative of discovering you have special powers and are chosen. In truth, the way I was selected to speak at the Web Directions Dev Summit this year wasn’t so spontaneous.

The organizer, John Allsopp, recommended my article “How to Discover a CSS Trick” on his reading list and connected with me on LinkedIn. I took the opportunity to pitch via direct message for a talk about scrolling since the proposal form on the Web Directions website felt comparatively impersonal. But now, what feels impersonal and daunting is the parallel-universe version of a train station that doesn’t exist back home except in my memory. Stepping onto the platform like an eleventh-hour rehearsal for the stage, I feel less like the Harry Potter of CSS and more like I’ve signed up to be a novelty museum exhibit. Step right up and laugh at the middle-aged dude who writes bizarre articles featuring a fictional seller of haunted CSS tricks who cursed him to overuse CSS for everything.

The spooky CSS shopkeeper is a figment of my imagination based on watching too many Simpsons reruns — but now I’ve manifested a real-life froghurt situation: a free conference ticket and trip to Sydney in exchange for embarrassing myself in front of the largest audience I’ve ever spoken to.

I procrastinate preparation by sitting down for frozen yoghurt in the Sydney CBD. The froghurt is yummy, but cursed by the cloud of anxiety following me around on this sunny day. So I’ll procrastinate describing my own talk to you by first sharing a few of my favorites from others.

Day One

I’ve arrived and the event kicks off.

Welcome: John Allsopp

The moment John takes the stage, I’m struck by his showmanship in subverting assumptions about his enthusiasm for tech. He opens by saying he feels ennui with web development, yet hopes the lineup over the next two days might snap him out of his pessimism about the web’s future.

It’s the conference equivalent of the literary technique of a frame story: He positions himself as a weary sage who will reappear after each talk for Q&A — and yet, as someone who predates PCs, he has greyed like an unavailable option on a computer screen. He fears he has seen too much to feel optimistic about the future of the web he helped to build.

He says front-end development has reached a “local maximum,” borrowing a term from calculus to explain how the tools that got us here have flattened our rate of change. The productivity boost is offset by the ways our tools limit imagination. Our mental models make it easy to build the same websites again and again, keeping us out of touch with what modern browsers can do.

He cites the View Transitions API — available as a progressive enhancement since 2023 — as an example of a native browser superpower that could subvert the SPA model, yet remains only experimentally supported in React.

The dramatic context for the next two days is now set. The web sucks, but prove him wrong, kids.

“The Browser Strikes Back: Rethinking the Modern Dev Stack” by Jono Alderson

“You’re gonna hate me,” says the keynote speaker Jono Alderson at the top of his talk on rethinking the modern dev stack.

He argues that frameworks like React are Rube Goldberg machines built around limitations that no longer exist. He compares them to Netflix’s DVD-by-mail era: We’re still sending discs when we could be streaming.

He runs through browser capabilities in 2025 that we routinely overlook when we reflexively reach for frameworks — and includes a teaser slide for my later talk on scroll timelines. I feel a sense of belonging and dread simultaneously, like passing the chicken exit on Space Mountain.

In the break, Jono admits to me that he was nervous about triggering anger by bashing frameworks. I hope the audience is warming to favoring the platform, because my talk shares that same underlying spirit, albeit through the specific example of CSS Scroll-Driven Animations. It helps that Jono served as frontline fodder, since research shows that everything sounds more credible with a British accent, even if Jono’s was slightly slurred from jet lag.

Whether he’s right about nuking frameworks or not, it’s healthy to reassess whether we need a dependency list longer than our screen port. I first questioned this in 2015 after watching Joe Gregorio argue we should stop using frameworks and rely on the platform — a talk that, in hindsight, looked suspiciously like guerrilla marketing for Google Polymer. I adopted Polymer for a major project. It was more like a framework than a library, but with the “bonus” of not being battle-tested like React: it had its own weird build process, reliance on a browser feature that never became a standard, and a promised future that never arrived. I ended up rewriting everything. Eventually, Polymer itself was quietly put out of its misery.

Even so, I love the idea of web components: transforming the browser into something built for the way we already force it to behave. A decade later, has the situation improved enough to yarn remove React? The answer may go beyond browser capability in 2025.

Over coffee, Jono and I discuss how LLMs are trained on oceans of React, reinforcing the assumption that every web app must be an SPA. Escaping React is harder than ever when the future of work is dragging us back into the past, much the way recommendation algorithms on social media trap us in our own echo chambers.

“It’s only gonna get worse,” says Jono.

And I guess it will, unless we start creating good examples of what browsers can do without dependencies.

“Supercharged Scrolling With CSS” by Me Photo credit: Kris Howard

It’s debatable whether you should admit you’re nervous while giving a talk. Most say you shouldn’t. The balance I strike is to open with a self-deprecating joke as a way to get the scrolling discussion rolling.

“I have a feeling some of you might be scrolling on your devices as we speak, so I urge you to look up — and let’s scroll together for the next half hour.”

It gets a laugh. It’s a moment where I translate my CSS-Tricks article style — self-referential, breaking the fourth wall — into something that works on stage. This is my challenge for the talk: How do I adapt a year’s worth of articles about my autistic special interest into thirty minutes?

It brings to mind the movie Adaptation, where Nicolas Cage plays a screenwriter with imposter syndrome trying to adapt an unfilmable book into the movie we’re watching. Unlike my articles, I decide I shouldn’t launch abruptly into the crazy CSS experiments I built in my basement.

First, I need to answer why me, this random guy, thinks scrollytelling warrants half an hour of the audience’s time. I can’t assume much about this audience. Kris Howard will later comment on her blog that “Lee Meyer’s session introduced me to a new term – scrollytelling.”

I borrow credibility from The New York Times, name-checking its high-profile examples of scrollytelling, one of which won a Pulitzer Prize. John helpfully drops the link to the “Snow Fall” article into the livestream chat, just as I’d add links if this were an article.

But there’s another element of my writing that doesn’t translate: long code snippets. They’re too complex to explain on stage. Doing so would be a suicide mission. Let’s do it anyway.

I’ve used reveal.js in the past for an online presentation at Games For Change, and reveal.js supports automatic animations between code blocks. I use that to demonstrate how newer CSS syntax can drastically shorten code. It doesn’t matter that nobody can fully parse the old syntax at a glance; that’s the point of the animation. I ask for a show of hands for who would rather write the new syntax than the old?

Adapting my articles for the stage is my opportunity to rewrite history to appear logical. The order of discovery of the building blocks I will use for my final demo appears intentional rather than the chaotic trail I have been leaving across CSS-Tricks since 2024. But now it’s time to tackle the final demo like the boss battle it is.

I ask for a show of hands: Should I fight the bad guy unarmed, or run away? The audience is split evenly, which is the one outcome I didn’t plan for.

In Adaptation, when Cage’s character is running out of time to finish his script, he panics and seeks advice from screenwriting guru Robert McKee, who tells him: Your story can be flawed throughout, but wow them in the end, and you’ve got a hit. As much as I’m my own worst critic, I know I have something with this final demo, the kind that would make a leader on the Google Chrome team tweet “Wow!” That tweet hasn’t happened yet while I’m on stage, as I’m wondering how this crowd will react.

I let the bad guy kill the hero first. I make the antagonist seem unbeatable. Then I refresh, scroll in the opposite direction, climb a ladder, collect a lightsaber, and kill the bad guy.

McKee warned Cage’s writer character not to cheat at the end with a deus ex machina. A magic lightsaber to save the day feels like one for sure, but by a stroke of synchronicity, Star Wars imagery has been appearing in talks all day. John Allsopp even joked that it’s a theme he didn’t get the memo about. I reference this overarching theme, and the lightsaber feels earned. The pixel art guy kills the bad guy with one blow. The applause is loud enough to be heard on the livestream, even though the audience isn’t miked.

Can we end on that high note? Research shows that time dilates for people onstage with high public-speaking anxiety. Ironically, in a talk about controlling timelines, I realize I’ve lost control of the time, and I’m about to run out of slides too early.

So, I replay the demo and discuss its subtext. The scrollytelling pixel guy can be a novelty toy or he can be ergodic literature, an autobiographical allegory. I refresh again. “Scroll left or right to flee or fight,” says the pixel art guy. I explain the deeper psychological truth behind the simplistic story and retro graphics.

“You can tell them anything if you just make it funny, make it rhyme, and if they still don’t understand you, then you run it one more time.”

— Bo Burnham in “Can’t Handle This” View Slides Happy Hour and Speaker Dinner

Every autistic person should receive a voucher that grants them access to one social situation where people come and talk to them about the thing they are obsessed with. One piece of feedback in particular made me feel seen: Someone tells me a more traditional tutorial would have been fine, but the direction I took was playful, which felt refreshing in a world where discussions of web development can become depressingly utilitarian. He doesn’t know that the first blog I ever created was playfulprogramming.com, so I’ve always been about finding joy in development.

Someone else told me it was their favorite talk in the conference, and that I was brave for embracing my Jewishness publicly by mentioning the Torah as an illustration of the meaning of scrolling to me. Given what happened in Sydney a month after I left, it may not have been bravery so much as my obliviousness to the current vibes in my country, since I am a more frequent reader of CSS-Tricks than the news.

Day Two

The Sydney weather cools, mirroring my more chilled mood today. With my presentation behind me, I now walk toward the venue like an anonymous attendee who magically got a free ticket. I brace myself for a morning of AI-heavy talks. My year at work was an AI overdose.

“What’s Beyond the Browser: The AI Platform Shift” by Rupert Manfredi

Walking to the University of Technology Sydney, the combo of venue and theme reminds me of John Barth’s Giles Goat-Boy, in which the world is represented as a university controlled by an AI. Authorship itself is disputed in the fictional preface, with both Barth and the AI claiming only to have edited the work — eerily prescient in 1966 of the state of work in 2026. AI is great until there’s a defect. Then humans blame the AI, and the AI blames humans for misunderstanding its limits.

The novel satirized the Cold War. A Marxist might say intellectual property can’t exist because creative work is always a product of the zeitgeist. Although the tech that Rupert Manfredi’s demos blurs the lines of authorship by doing away with discrete apps and websites and composing UIs to meet the user’s needs on the fly, he is probably not a commie. He suggests that creators would still get paid. Perhaps this will finally be the day in the sun for HTTP 402 Payment Required.

Rupert’s talk, “What’s Beyond the Browser,” is daring. He demos “Telepath,” a prototype computer with no browser and no apps. He envisages that future developers will transfer their skills to create only fragments and services that AI can synthesize into a tailored user experience. He argues web development has never really been about learning React hooks, but about solving user problems: critical paths, information quality, and creativity. These are more fundamental to a developer’s skillset than any tools they happen to use.

That resonates with how I think about my work on CSS-Tricks: They are fragments of expression that gain meaning when woven into a larger tapestry by the people or machines who learn from them. If basic functionality becomes trivial, developers can focus on the problems nobody has solved yet.

“A False Sense of Accessibility: What Automated Testing Tools Are Missing” by Beau Vass

As I mentioned before, I am autistic. So are my kids. It’s an invisible disability, and I’m careful to let the kids know the world won’t rearrange itself around our autism. Just as you can’t make something accessible to everyone, you can’t make the accessible experience the same as everyone else’s any more than you can make it easy for my son to succeed in a school system that was never designed with neurodiverse people in mind.

Accessibility is often less about universal comfort than about ensuring there’s a viable path for the people who truly need the content. When you think about it, the users’ faculties are part of the platform. Accessibility is, therefore, as fundamental as browser compatibility.

In his talk, “What Automated Tools Are Missing,” speaker Beau Vass demonstrates how automated audits flag non-issues while missing critical failures, sometimes making accessibility worse when followed blindly. A decorative image without alternative text might be flagged, yet adding it could also actively harm screen-reader users. The problem isn’t automated tools themselves; it’s when passing a Lighthouse audit becomes the goal. Tools only recognize what they’re taught, and AI trained on a broken web will faithfully reproduce its mistakes. As one of my workmates likes to say: “Use your tools, but don’t let them turn you into a tool.”

Accessibility isn’t a froghurt topping. It can’t be added at the end, not even in principle. The responsibility is shared across design, content, engineering, and testing, and it requires direct input from people with disabilities. Accessibility may be subjective, but making the web accessible should still be easier than making the physical world accessible. When we fail, it’s another reminder that tooling alone won’t save us.

AI won’t solve accessibility, but it may become useful once we stop asking it to. There aren’t enough good examples on the web for models to learn from, which means we can’t expect Claude Code to fix our sites. That said, AI can already simulate how a screen reader user might attempt to complete a task and surface where friction occurs. BrowserStack does this already. Ironically, it may be easier for a machine to put itself in the shoes of a disabled human than for a non-disabled human to do the same, and Beau believes it won’t be AI that changes the game, but laws and regulations requiring people to care about accessibility. Beau believes it’s more laws and regulations that will be a game-changer for accessibility than AI.

Departure

All flights are delayed an hour, as if Sydney itself is resisting my return to Melbourne — and the end of this article. But back when I was young and teaching myself to write, I read a book about writing articles that said the more a piece seems to be about everything, the more it’s about nothing. Soon, we must end the article.

It ends with me waiting to take flight, thinking about how Chris Coyier once said his greatest pride wasn’t a single moment of accomplishment, but the “aggregate moments” of sustained focus on his professional passions. The afterglow of this conference is the sum of a year obsessing over animation timelines — and what you’ll do with the knowledge if I end this article at the right moment.

But does that magical moment even exist? Animation timelines work because we can pause motion on a screen. But if we could do that in real life, then, according to Zeno’s arrow paradox, my plane could never land. At every bullet-time instant, the plane would appear at rest, which would make all movement — including my entire journey — an illusion.

John Allsopp worried that the web itself might be stuck in that illusion of progress. But Aristotle answered Zeno’s arrow paradox by saying discrete instants of time don’t exist, only the flow of time. Reality is made of the aggregate moments that Chris Coyier said have meaning to him. As I wait for a plane that seems incapable of landing, my phone buzzes with my favorite feedback from the conference: a graduate developer amazed by “the scroll section in the Dev Summit.” I love that he calls it a section, not a talk, as if it blended seamlessly into a two-day narrative flow, foreshadowing a future web that unfurls like an infinite scroll.

“This story will never end. This story ends.”

John Barth

Postcard From Web Directions Dev Summit, 2025 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

On Inheriting and Sharing Property Values

Mon, 11/24/2025 - 4:22am

Sometimes I want to set the value of a CSS property to that of a different property, even if I don’t know what that value is, and even if it changes later. Unfortunately though, that’s not possible (at least, there isn’t a CSS function that specifically does that).

In my opinion, it’d be super useful to have something like this (for interpolation, maybe you’d throw calc-size() in there as well):

/* Totally hypothetical */ button { border-radius: compute(height, self); border-radius: compute(height, inherit); border-radius: compute(height, #this); }

In 2021, Lea Verou explained why, despite being proposed numerous times, implementing such a general-purpose CSS function like this isn’t feasible. Having said that, I do remain hopeful, because things are always evolving and the CSSWG process isn’t always linear.

In the meantime, even though there isn’t a CSS function that enables us to get the value of a different property, you might be able to achieve your outcome using a different method, and those methods are what we’re going to look at today.

The fool-proof CSS custom properties method

We can easily get the value of a different CSS property using custom properties, but we’d need to know what the value is in order to declare the custom property to begin with. This isn’t ideal, but it does enable us to achieve some outcomes.

Let’s jump back to the example from the intro where we try to set the border-radius based on the height, only this time we know what the height is and we store it as a CSS custom property for reusability, and so we’re able to achieve our outcome:

button { --button-height: 3rem; height: var(--button-height); border-radius: calc(var(--button-height) * 0.3); }

We can even place that --button-height custom property higher up in the CSS cascade to make it available to more containment contexts.

:root { /* Declare here to use anywhere */ --button-height: 3rem; header { --header-padding: 1rem; padding: var(--header-padding); /* Height is unknown (but we can calculate it) */ --header-height: calc(var(--button-height) + (var(--header-padding) * 2)); /* Which means we can calculate this, too */ border-radius: calc(var(--header-height) * 0.3); button { /* As well as these, of course */ height: var(--button-height); border-radius: calc(var(--button-height) * 0.3); /* Oh, what the heck */ padding-inline: calc(var(--button-height) * 0.5); } } } CodePen Embed Fallback

I guess when my math teacher said that I’d need algebra one day. She wasn’t lying!

The unsupported inherit() CSS function method

The inherit() CSS function, which isn’t currently supported by any web browser, will enable us to get the value of a parent’s property. Think: the inherit keyword, except that we can get the value of any parent property and even modify it using value functions such as calc(). The latest draft of the CSS Values and Units Module Level 5 spec defines how this’d work for custom properties, which wouldn’t really enable us to do anything that we can’t already do (as demonstrated in the previous example), but the hope is that it’d work for all CSS properties further down the line so that we wouldn’t need to use custom properties (which is just a tad longer):

header { height: 3rem; button { height: 100%; /* Get height of parent but use it here */ border-radius: calc(inherit(height) * 0.3); padding-inline: calc(inherit(height) * 0.5); } }

There is one difference between this and the custom properties approach, though. This method depends on the fixed height of the parent, whereas with the custom properties method either the parent or the child can have the fixed height.

This means that inherit() wouldn’t interpolate values. For example, an auto value that computes to 3rem would still be inherited as auto, which might compute to something else when inherit()-ed., Sometimes that’d be fine, but other times it’d be an issue. Personally, I’m hoping that interpolation becomes a possibility at some point, making it far more useful than the custom properties method.

Until then, there are some other (mostly property-specific) options.

The aspect-ratio CSS property

Using the aspect-ratio CSS property, we can set the height relative to the width, and vice-versa. For example:

div { width: 30rem; /* height will be half of the width */ aspect-ratio: 2 / 1; /* Same thing */ aspect-ratio: 3 / 1.5; /* Same thing */ aspect-ratio: 10 / 5; /* width and height will be the same */ aspect-ratio: 1 / 1; }

Technically we don’t “get” the width or the height, but we do get to set one based on the other, which is the important thing (and since it’s a ratio, you don’t need to know the actual value — or unit — of either).

The currentColor CSS keyword

The currentColor CSS keyword resolves to the computed value of the color property. Its data type is <color>, so we can use it in place of any <color> on any property on the same element. For example, if we set the color to red (or something that resolves to red), or if the color is computed as red via inheritance, we could then declare border-color: currentColor to make the border red too:

body { /* We can set color here (and let it be inherited) */ color: red; button { /* Or set it here */ color: red; /* And then use currentColor here */ border-color: currentColor; border: 0.0625rem solid currentColor; background: hsl(from currentColor h s 90); } } CodePen Embed Fallback

This enables us to reuse the color without having to set up custom properties, and of course if the value of color changes, currentColor will automatically update to match it.

While this isn’t the same thing as being able to get the color of literally anything, it’s still pretty useful. Actually, if something akin to compute(background-color) just isn’t possible, I’d be happy with more CSS keywords like currentColor.

In fact, currentBackgroundColor/currentBackground has already been proposed. Using currentBackgroundColor for example, we could set the border color to be slightly darker than the background color (border-color: hsl(from currentBackgroundColor h s calc(l - 30))), or mix the background color with another color and then use that as the border color (border-color: color-mix(currentBackgroundColor, black, 30)).

But why stop there? Why not currentWidth, currentHeight, and so on?

The from-font CSS keyword

The from-font CSS keyword is exclusive to the text-decoration-thickness property, which can be used to set the thickness of underlines. If you’ve ever hated the fact that underlines are always 1px regardless of the font-size and font-weight, then text-decoration-thickness can fix that.

The from-font keyword doesn’t generate a value though — it’s optionally provided by the font maker and embedded into the font file, so you might not like the value that they provide, if they provide one at all. If they don’t, auto will be used as a fallback, which web browsers resolve to 1px. This is fine if you aren’t picky, but it’s nonetheless unreliable (and obviously quite niche).

We can, however, specify a percentage value instead, which will ensure that the thickness is relative to the font-size. So, if text-decoration-thickness: from-font just isn’t cutting it, then we have that as a backup (something between 8% and 12% should do it).

Don’t underestimate CSS units

You probably already know about vw and vh units (viewport width and viewport height units). These represent a percentage of the viewport’s width and height respectively, so 1vw for example would be 1% of the viewport’s width. These units can be useful by themselves or within a calc() function, and used within any property that accepts a <length> unit.

However, there are plenty of other, lesser-known units that can be useful in a similar way:

  • 1ex: equal to the computed x-height
  • 1cap: equal to the computed cap height
  • 1ch: equal to the computed width of the 0 glyph
  • 1lh: equal to the computed line-height (as long as you’re not trimming or adding to its content box, for example using text-box or padding, respectively, lh units could be used to determine the height of a box that has a fixed number of lines)
Source: W3

And again, you can use them, their logical variants (e.g., vi and vb), and their root variants (e.g., rex and rcap) within any property that accepts a <length> unit.

In addition, if you’re using container size queries, you’re also free to use the following container query units within the containment contexts:

  • 1cqw: equal to 1% of the container’s computed width
  • 1cqh: equal to 1% of the container’s computed height
  • 1cqi: equal to 1% of the container’s computed inline size
  • 1cqb: equal to 1% of the container’s computed block size
  • 1cqmin: equal to 1cqi or 1cqb, whichever is smallest
  • 1cqmax: equal to 1cqi or 1cqb, whichever is largest

That inherit() example from earlier, you know, the one that isn’t currently supported by any web browser? Here’s the same thing but with container size queries:

header { height: 3rem; container: header / size; @container header (width) { button { height: 100%; border-radius: calc(100cqh * 0.3); padding-inline: calc(100cqh * 0.5); } } } CodePen Embed Fallback

Or, since we’re talking about a container and its direct child, we can use the following shorter version that doesn’t create and query a named container (we don’t need to query the container anyway, since all we’re doing is stealing its units!):

header { height: 3rem; container-type: size; button { height: 100%; border-radius: calc(100cqh * 0.3); padding-inline: calc(100cqh * 0.5); } }

However, keep in mind that inherit() would enable us to inherit anything, whereas container size queries only enable us to inherit sizes. Also, container size queries don’t work with inline containers (that’s why this version of the container is horizontally stretched), so they can’t solve every problem anyway.

In a nutshell

I’m just going to throw compute() out there again, because I think it’d be a really great way to get the values of other CSS properties:

button { /* self could be the default */ border-radius: compute(height, self); /* inherit could work like inherit() */ border-radius: compute(height, inherit); /* Nice to have, but not as important */ border-radius: compute(height, #this); }

But if it’s just not possible, I really like the idea of introducing more currentColor-like keywords. With the exception of keywords like from-font where the font maker provides the value (or not, sigh), keywords such as currentWidth and currentHeight would be incredibly useful. They’d make CSS easier to read, and we wouldn’t have to create as many custom properties.

In the meantime though, custom properties, aspect-ratio, and certain CSS units can help us in the right circumstances, not to mention that we’ll be getting inherit() in the future. These are heavily geared towards getting widths and heights, which is fine because that’s undoubtedly the biggest problem here, but hopefully there are more CSS features on the horizon that allow values to be used in more places.

On Inheriting and Sharing Property Values originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Sketch: A guided tour of Copenhagen

Fri, 11/21/2025 - 8:53am

Sketch is getting a massive UI overhaul, codenamed Copenhagen:

Our latest update — Copenhagen — features a major redesign of Sketch’s UI. Redesigns like this don’t happen often. In fact, our last one was in 2020, when Apple launched macOS Big Sur.

Makes a lot of sense for an app that’s so tightly integrated to Mac to design around the macOS UI. Big Sur was a big update. Apple called it the biggest one since Mac OS X. So big, indeed, that they renamed Mac OS to macOS in the process. Now we have macOS Tahoe and while it isn’t billed the “biggest update since Big Sur” it does lean into an entirely new Liquid Glass aesthetic that many are calling the biggest design update to the Apple ecosystem since iOS 7.

Sketch probably didn’t “have” to redesign its UI to line up with macOS Tahoe, but a big part of its appeal is the fact that it feels like it totally belongs to the Mac. It’s the same for Panic apps.

The blog post I linked to sheds a good amount of light on the Sketch team’s approach to the updates. I came to the blog post to read about the attention they put into new features (individual page and frame link for the win!) and tightening up existing ones (that layer list looks nice), but what I really stayed for was their approach to Liquid Glass. Turns out they decided to respect it, but split lanes a bit:

Early on in the process, we prototyped various approaches to the sidebar and Inspector, including floating options (the new default in Tahoe) and glass materials. Ultimately, we went custom here, with fixed sidebars that felt less distracting in the context of a canvas-based design tool.

Spend a few seconds with an early prototype that leaned more heavily into Liquid Glass and it’s uber clear why a custom route was the best lane choice:

Still taken from one of the blog post’s embedded videos

Choosing a design editor can feel personal, can’t it? I know lots of folks are in the Figma Or Bust camp. Illustrator is still the favorite child for many, after all these… decades! There’s a lot of buzz around Affinity now that it’s totally free. I adopted Sketch a long time ago. How long? I dug up this dusty old blog post I wrote about Sketch 3 back in 2014, so at least 11 years.

But I’m more of a transient in the design editor space. Being a contractor and all, I have to be open to any app my clients might use internally, regardless of my personal preference. I’d brush up on Sketch’s UI updates even if it wasn’t my go-to.

Sketch: A guided tour of Copenhagen originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Should We Even Have :closed?

Thu, 11/20/2025 - 5:10am

For the past few months, I’ve been writing a lot of entries on pseudo-selectors in CSS, like ::picker() or ::checkmark. And, in the process, I noticed I tend to use the :open pseudo-selector a lot in my examples — and in my work in general.

Borrowing words from the fine author of the :open entry in the Almanac:

The CSS :open pseudo-selector targets elements that support open and closed states — such as the <details> and <select> elements — and selects them in their open state.

So, given this:

details:open { background: lightblue; color: darkred; }

We expect that the <details> element gets a light blue background and dark red text when it is in an open state (everywhere but Safari at the time I’m writing this):

CodePen Embed Fallback

But what if we want to select the “closed” state instead? That’s what we have the:closed pseudo-class for, right? It’s supposed to match an element’s closed state. I say, supposed because it’s not specced yet.

But does it need to be specced at all? I only ask because we can still target an element’s closed state without it using :not():

/* When details is _not_ open, but closed */ details:not(:open) { /* ... */ }

So, again: do we really need a :closed pseudo-class? The answer may surprise you! (Just kidding, this isn’t that sort of article…)

Some background

Talks surrounding :open started in May 2022 when Mason Freed raised the issue of adding :open (which was also considered being named :top-layer at the time) to target elements in the top layer (like popups):

Today, the OpenUI WC similarly resolved to add a :top-layer pseudo class that should apply to (at least) elements using the Popup API which are currently in the top layer. The intention for the naming and behavior, though, was that this pseudo class should also be general purpose. It should match any type of element in the top layer, including modal <dialog>, fullscreen elements, and ::backdrop pseudo elements.

This sparked discourse on whether the name of the pseudo-element targeting the top layer of any type of element (e.g., popups, pickers, etc.) should either be :open or :top-layer. I, for one, was thrilled when the CSSWG eventually decided on :open in August 2022. The name makes a lot more sense to me because “open” assumes something in the top layer.

To :close or :not(:open)?

Hold on, though! In September that same year, Mason asked whether or not we should have something like a :closed pseudo-class to accompany :open. That way, we can match elements in their “closed” states just as we can their “open” states. That makes a lot of sense, t least on the surface. Tab Atkins chimed in:

I love this definition, as I think it captures a concept of “openness” that lines up with what most developers think “open” means. I also think it makes it relatively straightforward for HTML to connect it to specific elements.

What do folks think?

Should we also talk about adding the corresponding :closed pseudo class? That would avoid the problem that :not(:open) can match anything, including things that don’t open or close.

And guess what? Everyone seemed to agree. Why? Because it made sense at the time. I mean, since we have a pseudo-class that targets elements in their :open state, surely it makes sense to have :closed to target elements in their closed states, right? Right??

No. There’s actually an issue with that line of reasoning. Joey Arhar made a comment about it in October that same year:

I opened a new issue about :closed because this doesn’t have consensus yet (#11039).

Wait, what happened to consensus? It’s the same question I raised at the top of this post. According to Luke Warlow:

Making :closed match things that can never be open feels odd. And would essentially make it :not(:open) in which case do we even need :closed? Like we don’t have a :popover-closed because it’s the inverse of :popover-open.

There is no :closed… for now

Fast forward one more month to November 2024. A consensus was made to start out with just :open and remove :closed for the time being.

Dang. Nevertheless, according to WHATWG and CSSWG, that decision could change in the future. In fact, Bramus dropped a useful note in there just a month before WHATWG made the decision:

Just dropping this as an FYI: :read-only is defined as :not(:read-write), and that shipped.

Which do you find easier to understand?

Personally, I’m okay with :closed — or even using :not(:open) — so far as it works. In fact, I went ahead swapped :closed for :not(:open) in my  ::checkmark and ::picker() examples. That’s why they are they way they are today.

But! If you were to ask me which one comes easier to me on a typical day, I think I would say :closed. It’s easier for me to think in literal terms than negated statements.

What do you think, though? Would you prefer having :closed or just leaving it as :not(:open)?

If you’re like me and you love following discussions like this, you can always head over to CSSWG drafts on GitHub to watch or participate in the fun.

Should We Even Have :closed? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Quiet UI Came and Went, Quiet as a Mouse

Fri, 11/14/2025 - 5:32am

A few weeks ago, Quiet UI made the rounds when it was released as an open source user interface library, built with JavaScript web components. I had the opportunity to check out the documentation and it seemed like a solid library. I’m always super excited to see more options for web components out in the wild.

Unfortunately, before we even had a chance to cover it here at CSS-Tricks, Quiet UI has disappeared. When visiting the Quiet UI website, there is a simple statement:

Unavailable

Quiet UI is no longer available to the general public. I will continue to maintain it as my personal creative outlet, but I am unable to release it to the world at this time.
Thanks for understanding. I’m really sorry for the inconvenience.

The repository for Quiet UI is no longer available on Quiet UI’s GitHub, and its social accounts seem to have been removed as well.

The creator, Cory LaViska, is a veteran of UI libraries and most known for work on Shoelace. Shoelace joined Font Awesome in 2022 and was rebranded as Web Awesome. The latest version of Web Awesome was released around the same time Quiet UI was originally announced.

According to the Quiet UI site, Cory will be continuing to work on it as a personal creative outlet, but hopefully we’ll be able to see what he’s cooking up again, someday. In the meantime, you can get a really good taste of what the project is/was all about in Dave Rupert’s fantastic write-up.

Quiet UI Came and Went, Quiet as a Mouse originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

The Range Syntax Has Come to Container Style Queries and if()

Thu, 11/13/2025 - 5:00am

The range syntax isn’t a new thing. We‘re already able to use it with media queries to query viewport dimensions and resolutions, as well as container size queries to query container dimensions. Being able to use it with container style queries — which we can do starting with Chrome 142 — means that we can compare literal numeric values as well as numeric values tokenized by custom properties or the attr() function.

In addition, this feature comes to the if() function as well.

Here’s a quick demo that shows the range syntax being used in both contexts to compare a custom property (--lightness) to a literal value (50%):

#container { /* Choose any value 0-100% */ --lightness: 10%; /* Applies it to the background */ background: hsl(270 100% var(--lightness)); color: if( /* If --lightness is less than 50%, white text */ style(--lightness < 50%): white; /* If --lightness is more than or equal to 50%, black text */ style(--lightness >= 50%): black ); /* Selects the children */ * { /* Specifically queries parents */ @container style(--lightness < 50%) { color: white; } @container style(--lightness >= 50%) { color: black; } } }

Again, you’ll want Chrome 142 or higher to see this work:

CodePen Embed Fallback

Both methods do the same thing but in slightly different ways.

Let’s take a closer look.

Range syntax with custom properties

In the next demo coming up, I’ve cut out the if() stuff, leaving only the container style queries. What’s happening here is that we’ve created a custom property called --lightness on the #container. Querying the value of an ordinary property isn’t possible, so instead we save it (or a part of it) as a custom property, and then use it to form the HSL-formatted value of the background.

#container { /* Choose any value 0-100% */ --lightness: 10%; /* Applies it to the background */ background: hsl(270 100% var(--lightness)); }

After that we select the container’s children and conditionally declare their color using container style queries. Specifically, if the --lightness property of #container (and, by extension, the background) is less than 50%, we set the color to white. Or, if it’s more than or equal to 50%, we set the color to black.

#container { /* etc. */ /* Selects the children */ * { /* Specifically queries parents */ @container style(--lightness < 50%) { color: white; } @container style(--lightness >= 50%) { color: black; } } } CodePen Embed Fallback

/explanation Note that we wouldn’t be able to move the @container at-rules to the #container block, because then we’d be querying --lightness on the container of #container (where it doesn’t exist) and then beyond (where it also doesn’t exist).

Prior to the range syntax coming to container style queries, we could only query specific values, so the range syntax makes container style queries much more useful.

By contrast, the if()-based declaration would work in either block:

#container { --lightness: 10%; background: hsl(270 100% var(--lightness)); /* --lightness works here */ color: if( style(--lightness < 50%): white; style(--lightness >= 50%): black ); * { /* And here! */ color: if( style(--lightness < 50%): white; style(--lightness >= 50%): black ); } } CodePen Embed Fallback

So, given that container style queries only look up the cascade (whereas if() also looks for custom properties declared within the same CSS rule) why use container style queries at all? Well, personal preference aside, container queries allow us to define a specific containment context using the container-name CSS property:

#container { --lightness: 10%; background: hsl(270 100% var(--lightness)); /* Define a named containment context */ container-name: myContainer; * { /* Specify the name here */ @container myContainer style(--lightness < 50%) { color: white; } @container myContainer style(--lightness >= 50%) { color: black; } } }

With this version, if the @container at-rule can’t find --lightness on myContainer, the block doesn’t run. If we wanted @container to look further up the cascade, we’d only need to declare container-name: myContainer further up the cascade. The if() function doesn’t allow for this, but container queries allow us to control the scope.

Range syntax with the attr() CSS function

We can also pull values from HTML attributes using the attr() CSS function.

In the HTML below, I’ve created an element with a data attribute called data-notifs whose value represents the number of unread notifications that a user has:

<div data-notifs="8"></div>

We want to select [data-notifs]::after so that we can place the number inside [data-notifs] using the content CSS property. In turn, this is where we’ll put the @container at-rules, with [data-notifs] serving as the container. I’ve also included a height and matching border-radius for styling:

[data-notifs]::after { height: 1.25rem; border-radius: 1.25rem; /* Container style queries here */ }

Now for the container style query logic. In the first one, it’s fairly obvious that if the notification count is 1-2 digits (or, as it’s expressed in the query, less than or equal to 99), then content: attr(data-notifs) inserts the number from the data-notifs attribute while aspect-ratio: 1 / 1 ensures that the width matches the height, forming a circular notification badge.

In the second query, which matches if the number is more than 99, we switch to content: "99+" because I don’t think that a notification badge could handle four digits. We also include some inline padding instead of a width, since not even three characters can fit into the circle.

To summarize, we’re basically using this container style query logic to determine both content and style, which is really cool:

[data-notifs]::after { height: 1.25rem; border-radius: 1.25rem; /* If notification count is 1-2 digits */ @container style(attr(data-notifs type(<number>)) <= 99) { /* Display count */ content: attr(data-notifs); /* Make width equal the height */ aspect-ratio: 1 / 1; } /* If notification count is 3 or more digits */ @container style(attr(data-notifs type(<number>)) > 99) { /* After 99, simply say "99+" */ content: "99+"; /* Instead of width, a little padding */ padding-inline: 0.1875rem; } } CodePen Embed Fallback

But you’re likely wondering why, when we read the value in the container style queries, it’s written as attr(data-notifs type(<number>) instead of attr(data-notifs). Well, the reason is that when we don’t specify a data type (or unit, you can read all about the recent changes to attr() here), the value is parsed as a string. This is fine when we’re outputting the value with content: attr(data-notifs), but when we’re comparing it to 99, we must parse it as a number (although type(<integer>) would also work).

In fact, all range syntax comparatives must be of the same data type (although they don’t have to use the same units). Supported data types include <length>, <number>, <percentage>, <angle>, <time>, <frequency>, and <resolution>. In the earlier example, we could actually express the lightness without units since the modern hsl() syntax supports that, but we’d have to be consistent with it and ensure that all comparatives are unit-less too:

#container { /* 10, not 10% */ --lightness: 10; background: hsl(270 100 var(--lightness)); color: if( /* 50, not 50% */ style(--lightness < 50): white; style(--lightness >= 50): black ); * { /* 50, not 50% */ @container style(--lightness < 50) { color: white; } @container style(--lightness >= 50) { color: black; } } }

Note: This notification count example doesn’t lend itself well to if(), as you’d need to include the logic for every relevant CSS property, but it is possible and would use the same logic.

Range syntax with literal values

We can also compare literal values, for example, 1em to 32px. Yes, they’re different units, but remember, they only have to be the same data type and these are both valid CSS <length>s.

In the next example, we set the font-size of the <h1> element to 31px. The <span> inherits this font-size, and since 1em is equal to the font-size of the parent, 1em in the scope of <span> is also 31px. With me so far?

According to the if() logic, if 1em is equal to less than 32px, the font-weight is smaller (to be exaggerative, let’s say 100), whereas if 1em is equal to or greater than 32px, we set the font-weight to a chunky 900. If we remove the font-size declaration, then 1em computes to the user agent default of 32px, and neither condition matches, leaving the font-weight to also compute to the user agent default, which for all headings is 700.

Basically, the idea is that if we mess with the default font-size of the <h1>, then we declare an optimized font-weight to maintain readability, preventing small-fat and large-thin text.

<h1> <span>Heading 1</span> </h1> h1 { /* The default value is 32px, but we overwrite it to 31px, causing the first if() condition to match */ font-size: 31px; span { /* Here, 1em is equal to 31px */ font-weight: if( style(1em < 32px): 100; style(1em > 32px): 900 ); } } CodePen Embed Fallback CSS queries have come a long way, haven’t they?

In my opinion, the range syntax coming to container style queries and the if() function represents CSS’s biggest leap in terms of conditional logic, especially considering that it can be combined with media queries, feature queries, and other types of container queries (remember to declare container-type if combining with container size queries). In fact, now would be a great time to freshen up on queries, so as a little parting gift, here are some links for further reading:

The Range Syntax Has Come to Container Style Queries and if() originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Headings: Semantics, Fluidity, and Styling — Oh My!

Mon, 11/10/2025 - 4:44am

A few links about headings that I’ve had stored under my top hat.

“Page headings don’t belong in the header”

Martin Underhill:

I’ll start with where the <h1> should be placed, and you’ll start to see why the <header> isn’t the right location: it’s the header for the page, and the main page content should live within the <main> element.

A classic conundrum! I’ve seen the main page heading (<h1>) placed in all kinds of places, such as:

  • The site <header> (wrapping the site title)
  • A <header> nested in the <main> content
  • A dedicated <header> outside the <main> content

Aside from that first one — the site title serves a different purpose than the page title — Martin pokes at the other two structures, describing how the implicit semantics impact the usability of assistive tech, like screen readers. A <header> is a wrapper for introductory content that may contain a heading element (in addition to other types of elements). Similarly, a heading might be considered part of the <main> content rather than its own entity.

So:

<!-- 1️⃣ --> <header> <!-- Header stuff --> <h1>Page heading</h1> </header> <main> <!-- Main page content --> </main> <!-- 2️⃣ --> <main> <header> <!-- Header stuff --> <h1>Page heading</h1> </header> <!-- Main page content --> </main>

Like many of the decisions we make in our work, there are implications:

  • If the heading is in a <header> that is outside of the <main> element, it’s possible that a user will completely miss the heading if they jump to the main content using a skip link. Or, a screenreader user might miss it when navigating by landmark. Of course, it’s possible that there’s no harm done if the first user sees the heading prior to skipping, or if the screenreader user is given the page <title> prior to jumping landmarks. But, at worst, the screenreader will announce additional information about reaching the end of the banner (<header> maps to role="banner") before getting to the main content.
  • If the heading is in a <header> that is nested inside the <main> element, the <header> loses its semantics, effectively becoming a generic <div> or <section>, thus introducing confusion as far as where the main page header landmark is when using a screenreader.

All of which leads to Martin to a third approach, where the heading should be directly in the <main> content, outside of the <header>:

<!-- 3️⃣ --> <header> <!-- Header stuff --> </header> <main> <h1>Page heading</h1> <!-- Main page content --> </main>

This way:

  • The <header> landmark is preserved (as well as its role).
  • The <h1> is connected to the <main> content.
  • Navigating between the <header> and <main> is predictable and consistent.

As Martin notes: “I’m really nit-picking here, but it’s important to think about things beyond the visually obvious.”

Read article “Fluid Headings”

Donnie D’Amato:

There’s no shortage of posts that explain how to perform responsive typography. […] However, in those articles no one really mentions what qualities you are meant to look out for when figuring out the values. […] The recommendation there is to always include a non-viewport unit in the calculation with your viewport unit.

To recap, we’re talking about text that scales with the viewport size. That usually done with the clamp() function, which sets an “ideal” font size that’s locked between a minimum value and a maximum value it can’t exceed.

.article-heading { font-size: clamp(<min>, <ideal>, <max>); }

As Donnie explains, it’s common to base the minimum and maximum values on actual font sizing:

.article-heading { font-size: clamp(18px, <ideal>, 36px); }

…and the middle “ideal” value in viewport units for fluidity between the min and max values:

.article-heading { font-size: clamp(18px, 4vw, 36px); }

But the issue here, as explained by Maxwell Barvian on Smashing Magazine, is that this muffs up accessibility if the user applies zooming on the page. Maxwell’s idea is to use a non-viewport unit for the middle “ideal” value so that the font size scales to the user’s settings.

Donnie’s idea is to calculate the middle value as the difference between the min and max values and make it relative to the difference between the maximum number of characters per line (something between 40-80 characters) and the smallest viewport size you want to support (likely 320px which is what we traditionally associate with smaller mobile devices), converted to rem units, which .

.article-heading { --heading-smallest: 2.5rem; --heading-largest: 5rem; --m: calc( (var(--heading-largest) - var(--heading-smallest)) / (30 - 20) /* 30rem - 20rem */ ); font-size: clamp( var(--heading-smallest), var(--m) * 100vw, var(--heading-largest) ); }

I couldn’t get this working. It did work when swapping in the unit-less values with rem. But Chrome and Safari only. Firefox must not like dividing units by other units… which makes sense because that matches what’s in the spec.

Anyway, here’s how that looks when it works, at least in Chrome and Safari.

CodePen Embed Fallback Read article Style :headings

Speaking of Firefox, here’s something that recently landed in Nightly, but nowhere else just yet.

Alvaro Montoro:

Styling headings in CSS is about to get much easier. With the new :heading pseudo-class and :heading() function, you can target headings in a cleaner and more flexible way.

  • :heading: Selects all <h*> elements.
  • :heading(): Same deal, but can select certain headings instead of all.

I scratched my head wondering why we’d need either of these. Alvaro says right in the intro they select headings in a cleaner, more flexible way. So, sure, this:

:heading { }

…is much cleaner than this:

h1, h2, h3, h4, h5, h6 { }

Just as:

:heading(2, 3) {}

…is a little cleaner (but no shorter) than this:

h2, h3 { }

But Alvaro clarifies further, noting that both of these are scoped tightly to heading elements, ignoring any other element that might be heading-like using HTML attributes and ARIA. Very good context that’s worth reading in full.

Read article

Headings: Semantics, Fluidity, and Styling — Oh My! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

©2003 - Present Akamai Design & Development.