Developer News
Distinguishing “Components” and “Utilities” in Tailwind
Here’s a really quick tip. You can think of Tailwind utilities as components — because you can literally make a card “component” out of Tailwind utilities.
@utility card { border: 1px solid black; padding: 1rlh; } <div class="card"> ... </div>This blurs the line between “Components” and “Utilities” so we need to better define those terms.
The Great Divide — and The Great UnificationCSS developers often define Components and Utilities like this:
- Component = A group of styles
- Utility = A single rule
This collective thinking has emerged from the terminologies we have gathered over many years. Unfortunately, they’re not really the right terminologies.
So, let’s take a step back and consider the actual meaning behind these words.
Component means: A thing that’s a part of a larger whole.
Utility means: It’s useful.
So…
- Utilities are Components because they’re still part of a larger whole.
- Components are Utilities because they’re useful.
The division between Components and Utilities is really more of a marketing effort designed to sell those utility frameworks — nothing more than that.
It. Really. Doesn’t. Matter.
The meaningful divide?Perhaps the only meaningful divide between Components and Utilities (in the way they’re commonly defined so far) is that we often want to overwrite component styles.
It kinda maps this way:
- Components: Groups of styles
- Utilities: Styles used to overwrite component styles.
Personally, I think that’s a very narrow way to define something that actually means “useful.”
Just overwrite the dang styleTailwind provides us with an incredible feature that allows us to overwrite component styles. To use this feature, you would have to:
- Write your component styles in a components layer.
- Overwrite the styles via a Tailwind utility.
But this is a tedious way of doing things. Imagine writing @layer components in all of your component files. There are two problems with that:
- You lose the ability to use Tailwind utilities as components
- You gotta litter your files with many @layer component declarations — which is one extra indentation and makes the whole CSS a little more difficult to read.
There’s a better way of doing this — we can switch up the way we use CSS layers by writing utilities as components.
@utility card { padding: 1rlh; border: 1px solid black; }Then, we can overwrite styles with another utility using Tailwind’s !important modifier directly in the HTML:
<div class="card !border-blue-500"> ... </div>I put together an example over at the Tailwind Playground.
Unorthodox TailwindThis article comes straight from my course, Unorthodox Tailwind, where you’ll learn to use CSS and Tailwind in a synergistic way. If you liked this, there’s a lot more inside: practical ways to think about and use Tailwind + CSS that you won’t find in tutorials or docs.
Check it outDistinguishing “Components” and “Utilities” in Tailwind originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Spiral Scrollytelling in CSS With sibling-index()
Confession time: I’ve read about the performance benefits of scroll-timeline(), but when I see an impressive JavaScript scrollytelling site like this one, it makes me question if the performance of old-school, main-thread scrollytelling is all that bad. The other shoe drops when the creators of that site admit they “ran into real limits,” and “mobile technically works, but it loses parallax and chops compositions,” to the extent that they “chose to gate phones to protect the first impression.” Put another way: they couldn’t get it working on mobile, and it sounds like JavaScript performance may have been one of the culprits.
The creator of another of my favorite scrolling experiments — which also uses JavaScript and also works best on desktop — called out that his text vortex section “would look better if it were applied for each character rather than each word, but that’s incredibly difficult to pull off using this same technique without incurring an astronomical performance impact.”
Challenge accepted.
He may have inadvertently created a realistic benchmark test for smoothly animating hundreds of divs based on scrolling.
That’s our cue to see if we can make a lookalike effect using modern CSS features to smoothly spiral every character in a string of text as the user scrolls down. To give the original text vortex some CSS sibling rivalry, let’s give the new sibling-index() function a whirl, although it is still waiting on Firefox support at the time of writing. Therefore, as a fallback for the CodePen below, you can also watch the video of the screen recording.
CodePen Embed Fallback Confession #2: This uses some scriptThe only JavaScript is to split the text into a <div> for each character, but the animation is pure CSS. I could have hardcoded all the markup instead, but that would make the HTML annoying to read and maintain. The following script makes it easy for you to experiment with the pen by tweaking the text content.
const el = document.querySelector(".vortex"); el.innerHTML = el.innerHTML.replaceAll(/\s/g, '⠀'); new SplitText(".title", { type: "chars", charsClass: "char" });The SplitText plugin referenced here is from the freely available GSAP library. The plugin is designed to be usable standalone outside GSAP, which is what’s happening here. It is nice and simple to use, and it even populates aria-label so screen readers can see our text, regardless of the way we tokenize it. The one complication was that I wanted every space character to be in its own <div> that I could position. The simplest way I could find was to replace the spaces with a special space character, which SplitText will put into its own <div>. If anyone knows a better way, I’d love to hear about it in the comments.
Now that we have each character living in its own <div>, we can implement the CSS to handle the spiral animation.
.vortex { position: fixed; left: 50%; height: 100vh; animation-name: vortex; animation-duration: 20s; animation-fill-mode: forwards; animation-timeline: scroll(); .char { --radius: calc(10vh - (7vh/sibling-count() * sibling-index())); --rotation: calc((360deg * 3/sibling-count()) * sibling-index()); position: absolute !important; top: 50%; left: 50%; transform: rotate(var(--rotation)) translateY(calc(-2.9 * var(--radius))) scale(calc(.4 - (.25/(sibling-count()) * sibling-index()))); animation-name: fade-in; animation-ranger-start: calc(90%/var(sibling-count()) * var(--sibling-index())); animation-fill-mode: forwards; animation-timeline: scroll(); } } Spiral and fade the elements using sibling-index() and sibling-count()We use the sibling-count and sibling-index functions together to calculate a gradual decrease for several properties of the characters when the sibling-index increases, using a formula like this:
propertyValue = startValue - ((reductionValue/totalCharacters) * characterIndex)The first character starts near the maximum value. Each subsequent character subtracts a slightly larger fraction, so properties gradually dwindle to a chosen target value as the characters spiral inward. This technique is used to drive scale, rotation, and distance from the center.
If the goal had been to arrange the characters in a circle instead of a spiral, I would have used CSS trigonometric functions as demonstrated here. However, the spiral seemed simpler to calculate without trig. Evidently, the original JavaScript version that inspired my CSS text spiral didn’t use trig either. The scroll animation is relatively simple as it’s just scaling and rotating the entire parent element to give the illusion that the viewer is being sucked into the vortex.
The only animation applied to individual characters is fade-in which is delayed increasingly for each character in the string, using another variation on the usage of the ratio of sibling-index() to sibling-count(). In this case, we increment animation-range-start to stagger the delay before characters fade in as the user scrolls. It’s reminiscent of the infamous scroll-to-fade effect, and it makes me realize how often we reach for JavaScript just because it allows us to base styling on element indexes. Therefore, many JavaScript effects can likely be replaced with CSS once sibling-index() goes Baseline. Please do let me know in the comments if you can think of other examples of JavaScript effects we could recreate in CSS using sibling-index().
Spiral Scrollytelling in CSS With sibling-index() originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Interop 2026
Interop 2026 is formally a thing. So, you know all of those wild, new CSS features we’re always poking at but always putting under a “lack of browser support” caveat? The Big Three — Blink (Chrome/Edge), WebKit (Safari), and Mozilla (Firefox) — are working together to bring full and consistent support to them!
You can read the blog posts yourself:
- Interop 2026: Continuing to improve the web for developers (web.dev)
- Announcing Interop 2026 (WekBit Blog)
- Launching Interop 2026 (Mozilla)
- Microsoft Edge and Interop 2026 (Edge)
An, yes, there’s plenty to get excited about specifically for CSS:
Anchor positioningFrom our guide:
CSS Anchor Positioning gives us a simple interface to attach elements next to others just by saying which sides to connect — directly in CSS. It also lets us set a fallback position so that we can avoid the overflow issues we just described.
anchor positioning Article on Apr 28, 2025 Anchor Positioning Just Don’t Care About Source Order Geoff Graham accessibility anchor positioning positioning Article on Sep 10, 2024 Anchor Positioning Quirks Juan Diego Rodríguez anchor positioning Article on Oct 2, 2024 CSS Anchor Positioning Guide Juan Diego Rodríguez Interest Invoker API popover Article on Jul 23, 2025 A First Look at the Interest Invoker API (for Hover-Triggered Popovers) Daniel Schwarz anchor positioning Links & URLs Article on Jan 17, 2025 Fancy Menu Navigation Using Anchor Positioning Temani Afif Advanced attr()We’ve actually had the attr() function for something like 15 years. But now we’re gonna be able to pass variables in there… with type conversion!
Almanac on Mar 27, 2025 attr() .element { color: attr(data-color type()); } Daniel Schwarz charts data visualization Article on Feb 5, 2026 CSS Bar Charts Using Modern Functions Preethi attr() custom properties Article on Nov 2, 2017 The CSS attr() function got nothin’ on custom properties Chris Coyier Container style queriesWe can already query containers by “type” but only by size. It’ll be so much cooler when we can apply styles based on other styles. Say:
@container style((font-style: italic) and (--color-mode: light)) { em, i, q { background: lightpink; } } container-queries Article on Jun 9, 2021 A Cornucopia of Container Queries Geoff Graham container-queries Article on Oct 12, 2022 Early Days of Container Style Queries Geoff Graham container-queries Article on Dec 1, 2022 Digging Deeper Into Container Style Queries Geoff Graham Scroll Driven Animation Style Queries Article on Mar 31, 2025 Worlds Collide: Keyframe Collision Detection Using Style Queries Lee Meyer container-queries Article on Jun 10, 2024 CSS Container Queries Geoff Graham The contrast-color() functionGetting the right color contrast between foreground text and background can be easy enough, but it’s been more of a manual type thing that we might switch with a media query based on the current color scheme. With contrast-color() (I always want to write that as color-contrast(), maybe because that was the original name) we can dynamically toggle the color between white and black.
button { --background-color: darkblue; background-color: var(--background-color); color: contrast-color(var(--background-color)); } color-contrast() safari Article on Apr 26, 2021 Exploring color-contrast() for the First Time Chris Coyier accessibility color CSS functions Article on Jun 5, 2025 Exploring the CSS contrast-color() Function… a Second Time Daniel Schwarz color CSS functions Link on Oct 8, 2025 The thing about contrast-color Geoff Graham accessibility color color-contrast() relative color Article on Feb 11, 2026 Approximating contrast-color() With Other CSS Features Kevin Hamer Custom HighlightsHighlight all the things! We’ve had ::selection forever, but now we’ll have a bunch of others:
Pseudo-selectorSelects…Notes::search-textFind-in-page matches::search-text:currentselects 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 ::selection custom highlight api selecting text Article on Mar 1, 2022 CSS Custom Highlight API: The Future of Highlighting Text Ranges on the Web Patrick Brosset pseudo elements Article on Jan 28, 2026 Styling ::search-text and Other Highlight-y Pseudo-Elements Daniel Schwarz Dialogs and popoversFinally, a JavaScript-less (and declarative) way to set elements on the top layer! We’ve really dug into these over the years.
accessibility dialog popover Article on Oct 23, 2024 Clarifying the Relationship Between Popovers and Dialogs Zell Liew popover Article on Jun 26, 2024 Poppin’ In Geoff Graham accessibility dialog modal Article on Jan 26, 2026 There is No Need to Trap Focus on a Dialog Element Zell Liew game popover Article on Jul 25, 2024 Pop(over) the Balloons John Rhea anchor positioning popover WordPress Article on Feb 19, 2025 Working With Multiple CSS Anchors and Popovers Inside the WordPress Loop Geoff Graham popover Article on Jun 9, 2025 Creating an Auto-Closing Notification With an HTML Popover Preethi Interest Invoker API popover Article on Jul 23, 2025 A First Look at the Interest Invoker API (for Hover-Triggered Popovers) Daniel Schwarz dialog Article on Jun 3, 2025 Getting Creative With HTML Dialog Andy Clarke dialog modal pseudo elements Link on Jan 15, 2018 Meet the New Dialog Element Robin Rendle css properties dialog Link on Dec 1, 2025 Prevent a page from scrolling while a dialog is open Geoff Graham attributes dialog popover Article on Nov 20, 2024 Invoker Commands: Additional Ways to Work With Dialog, Popover… and More? Daniel Schwarz accessibility dialog Article on Oct 7, 2019 Some Hands-On with the HTML Dialog Element Chris Coyier Media pseudo-classesHow often have you wanted to style an <audio> or <video> element based on its state? Perhaps with, JavaScript, right? We’ll have several states in CSS to work off:
- :playing
- :paused
- :seeking
- :buffering
- :stalled
- :muted
- :volume-locked
I love this example from the WebKit announcement:
video:buffering::after { content: "Loading..."; } Scroll-driven animationsOK, we all want this one. We’re talking specifically about animation that responds to scrolling. In other words, there’s a direct link between scrolling progress and the animation’s progress.
#progress { animation: grow-progress linear forwards; animation-timeline: scroll(); } carousel Article on May 15, 2025 Scroll-Driven Animations Inside a CSS Carousel Geoff Graham position Scroll Driven Animation Article on Jul 11, 2025 Scroll-Driven Sticky Heading Amit Sheen animation Scroll Driven Animation Article on Aug 6, 2025 Bringing Back Parallax With Scroll-Driven CSS Animations Blake Lundquist Scroll Driven Animation Article on May 5, 2025 Modern Scroll Shadows Using Scroll-Driven Animations Kevin Hamer Scroll Driven Animation Link on Feb 13, 2025 Scroll Driven Animations Notebook Geoff Graham animation Scroll Driven Animation Article on Oct 21, 2024 Unleash the Power of Scroll-Driven Animations Geoff Graham Scroll Driven Animation Article on Sep 27, 2024 Slide Through Unlimited Dimensions With CSS Scroll Timelines Lee Meyer Scroll Driven Animation Article on Nov 1, 2024 Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations Lee Meyer Scroll snappingNothing new here, but bringing everyone in line with how the specs have changed over the years!
Almanac on Feb 13, 2019 scroll-margin .scroll-element { scroll-margin: 50px 0 0 50px; } Andy Adams Almanac on Feb 12, 2019 scroll-padding .scroll-element{ scroll-padding: 50px 0 0 50px; } Andy Adams Almanac on Feb 21, 2019 scroll-snap-align .element { scroll-snap-align: start; } Andy Adams Almanac on Mar 7, 2019 scroll-snap-stop .element { scroll-snap-stop: always; } Andy Adams Almanac on Feb 4, 2019 scroll-snap-type .scroll-element { scroll-snap-type: y mandatory; } Andy Adams public speaking scroll-snap Article on Feb 7, 2022 CSS Scroll Snap Slide Deck That Supports Live Coding Stephanie Eckles scroll-snap twitter user styles Article on Aug 5, 2022 How I Added Scroll Snapping To My Twitter Timeline Šime Vidas scroll-snap Link on Mar 27, 2020 How to use CSS Scroll Snap Chris Coyier scroll-snap Article on Mar 2, 2016 Introducing CSS Scroll Snap Points Sarah Drasner scroll-snap scrolling Article on Aug 15, 2018 Practical CSS Scroll Snapping Max Kohler The shape() functionThis is one that Temani has been all over lately and his SVG Path to Shape Converter is a must-bookmark. The shape() can draw complex shapes when clipping elements with the clip-path property. We’ve had the ability to draw basic shapes for years — think circle, ellipse(), and polygon() — but no “easy” way to draw more complex shapes. And now we have something less SVG-y that accepts CSS-y units, calculations, and whatnot.
.clipped { width: 250px; height: 100px; box-sizing: border-box; background-color: blue; clip-path: shape( from top left, hline to 100%, vline to 100%, curve to 0% 100% with 50% 0%, ); } CSS functions Article on May 2, 2025 CSS shape() Commands Geoff Graham art clip-path CSS functions css shapes Article on May 23, 2025 Better CSS Shapes Using shape() — Part 1: Lines and Arcs Temani Afif art clip-path CSS functions css shapes Article on May 30, 2025 Better CSS Shapes Using shape() — Part 2: More on Arcs Temani Afif art clip-path CSS functions css shapes Article on Jun 6, 2025 Better CSS Shapes Using shape() — Part 3: Curves Temani Afif art clip-path CSS functions css shapes Article on Jul 7, 2025 Better CSS Shapes Using shape() — Part 4: Close and Move Temani Afif CSS functions resource shapes Link on May 21, 2025 SVG to CSS Shape Converter Geoff Graham View transitionsThere are two types of view transitions: same-document (transitions on the same page) and cross-document (or what we often call multi-page transitions). Same-page transitions went Baseline in 2025 and now browsers are working to be cross-compatible implementations of cross-document transitions.
view transitions Article on Feb 21, 2025 Toe Dipping Into View Transitions Geoff Graham view transitions Article on Jan 29, 2025 What on Earth is the `types` Descriptor in View Transitions? Juan Diego Rodríguez view transitions Almanac on Jun 7, 2024 ::view-transition ::view-transition { position: fixed; } Geoff Graham view transitions Almanac on Jun 12, 2024 ::view-transition-group ::view-transition-group(transition-name) { animation-duration: 1.25s; } Geoff Graham view transitions Almanac on Jun 14, 2024 ::view-transition-image-new ::view-transition-image-new(*) { animation-duration: 700ms; } Geoff Graham view transitions Almanac on ::view-transition-image-old ::view-transition-image-old(*) { animation-duration: 700ms; } Geoff Graham view transitions Almanac on ::view-transition-image-pair ::view-transition-image-pair(root) { animation-duration: 1s; } Geoff Graham view transitions Almanac on Jan 22, 2026 ::view-transition-new() ::view-transition-new(item) { animation-name: fade-in; } Sunkanmi Fafowora view transitions Almanac on ::view-transition-old() ::view-transition-old(item) { animation-name: fade-out; } Sunkanmi Fafowora view transitions Almanac on Jan 22, 2025 @view-transition @view-transition { navigation: auto; } Juan Diego Rodríguez Almanac on Jul 2, 2025 view() .element { animation-timeline: view(); } Saleh Mubashar view transitions Almanac on Jan 20, 2026 view-transition-class .element { view-transition-class: bearhugs; } Sunkanmi Fafowora view transitions Almanac on May 29, 2024 view-transition-name .element { view-transition-name: image-zoom; } Geoff Graham CSS zoom propertyOh, I wasn’t expecting this! I mean, we’ve had zoom for years — our Almanac page was published back in 2011 — but as a non-standard property. I must have overlooked that it was Baseline 2024 newly available and worked on as part of Interop 2025. It’s carrying over into this year.
zoom is sorta like the scale() function, but it actually affects the layout whereas scale() it’s merely visual and will run over anything in its way.
That’s a wrap! Bookmark the Interop 2026 Dashboard to keep tabs on how things are progressing along.
Interop 2026 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Showing the Work of Agents in UI
As AI products lean more heavily into agentic capabilities, the same design challenges keep surfacing across projects. Here's a look at how we've approached one of these recurring debates: showing the work of agents, or not.
An AI product becomes agentic when the model doesn't just respond to a prompt, but plans which tools to use, configures them, and decides its next steps based on the results. This additional set of process means AI products are able to do more, check their work, and thereby provide better results. The downside, though, is it can be a lot for people to take in.
Whether people are using agentic products for coding, data analysis, or writing, I keep seeing the same split: some users find the agent's work overwhelming and want the interface to focus purely on results. Others say seeing that work is essential for monitoring and checking what the agent is doing. Strongly worded feedback comes in from both sides.
I initially assumed this was a temporary divide. New users tend to watch closely and check the system's progress, but as trust builds, that scrutiny fades and monitoring starts to feel like a chore. Yet it still seems like there's two camps (for now). So how does a product strike the balance?
When working on Bench, a workspace for knowledge work, we explored many approaches to displaying tool use, results, and configuration. (though we quickly learned, no one configured tools, that's the agent's job.) In this exploration, results from each tool are grouped beneath it and open in the right column when selected (video below).
A later iteration featured several levels of progressive disclosure. Tool calls were collapsed by default, and selecting one would show its results in the right column. Selecting the timeline highlighted all the process and decision points between tool uses. You could even open each tool's settings, re-run it, or stop it mid-execution (video below). Tools were new back then and we were working off the assumption that people would want visibility and control. It was too much.
In subsequent iterations we focused on reducing the visual weight of tools and showing less process by default. This became even more important as the number of tools grew..
For ChatDB, which helps people understand and visualize data, we split the interface into two columns. While the agent works (video below), the left side shows what it's doing: the decisions it's making, the tools it's picking, and so on. When results appear in the right column, the left side collapses down to a summary and link so the focus shifts to the output. Anyone who wants to review the steps can open it back up.
This approach allows the agent's work to serve a detailed progress indicator, instead of forcing people to watch a spinner while things work.
More recently in Intent, a developer workspace for working with agents, we used a single line to show an agent's work with the ability to expand it for more details. It's an attempt to strike a balance between too much and not enough but I still hear opinions on both sides.
Making a Responsive Pyramidal Grid With Modern CSS
In the previous article, we built the classic hexagon grid. It was a responsive implementation without the use of media queries. The challenge was to improve a five-year old approach using modern CSS.
Support is limited to Chrome only because this technique uses recently released features, including corner-shape, sibling-index(), and unit division.
CodePen Embed FallbackIn this article, we will explore another type of grid: a pyramidal one. We are still working with hexagon shapes, but a different organization of the elements.
A demo worth a thousand words:
CodePen Embed FallbackFor better visualization, open the full-page view of the demo to see the pyramidal structure. On screen resize, you get a responsive behavior where the bottom part starts to behave similarly to the grid we created in the previous article!
Cool right? All of this was made without a single media query, JavaScript, or a ton of hacky CSS. You can chunk as many elements as you want, and everything will adjust perfectly.
Before we start, do yourself a favor and read the previous article if you haven’t already. I will skip a few things I have already explained there, such as how the shapes are created as well as a few formulas I will reuse here. Similar to the previous article, the implementation of the pyramidal grid is an improvement of a five-year old approach, so if you want to make a comparison between 2021 and 2026, check out that older article as well.
The Initial ConfigurationThis time, we will rely on CSS Grid instead of Flexbox. With this structure, it’s easy to control the placement of items inside columns and rows rather than adjusting margins.
<div class="container"> <div></div> <div></div> <div></div> <div></div> <!-- etc. --> </div> .container { --s: 40px; /* size */ --g: 5px; /* gap */ display: grid; grid-template-columns: repeat(auto-fit, var(--s) var(--s)); justify-content: center; gap: var(--g); } .container > * { grid-column-end: span 2; aspect-ratio: cos(30deg); border-radius: 50% / 25%; corner-shape: bevel; margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg))); }I am using the classic repeated auto-fit to create as many columns as the free space allows. For the items, it’s the same code of the previous article for creating hexagon shapes.
You wrote var(--s) twice. Is that a typo?
It’s not! I want my grid to always have an even number of columns, where each item spans two columns (that’s why I am using grid-column-end: span 2). With this configuration, I can easily control the shifting between the different rows.
Above is a screenshot of DevTools showing the grid structure. If, for example, item 2 spans columns 3 and 4, then item 4 should span columns 2 and 3, item 5 should span columns 4 and 5, and so on.
It’s the same logic with the responsive part. Each first item of every other row is shifted by one column and starts on the second column.
With this configuration, the size of an item will be equal to 2*var(--s) + var(--g). For this reason, the negative bottom margin is different from the previous example.
So, instead of this:
margin-bottom: calc(var(--s)/(-4*cos(30deg)));…I am using:
margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg))); CodePen Embed FallbackNothing fancy so far, but we already have 80% of the code. Believe it or not, we are only one property away from completing the entire grid. All we need to do is set the grid-column-start of a few elements to have the correct placement and, as you may have guessed, here comes the trickiest part involving a complex calculation.
The Pyramidal GridLet’s suppose the container is large enough to contain the pyramid with all the elements. In other words, we will ignore the responsive part for now. Let’s analyze the structure and try to identify the patterns:
Regardless of the number of items, the structure is somehow static. The items on the left (i.e., the first item of each row) are always the same (1, 2, 4, 7, 11, and so on). A trivial solution is to target them using the :nth-child() selector.
:nth-child(1) { grid-column-start: ?? } :nth-child(2) { grid-column-start: ?? } :nth-child(4) { grid-column-start: ?? } :nth-child(7) { grid-column-start: ?? } :nth-child(11) { grid-column-start: ?? } /* etc. */The positions of all of them are linked. If item 1 is placed in column x, then item 2 should be placed in column x - 1, item 4 in column x - 2, and so forth.
:nth-child(1) { grid-column-start: x - 0 } /* 0 is not need but useful to see the pattern*/ :nth-child(2) { grid-column-start: x - 1 } :nth-child(4) { grid-column-start: x - 2 } :nth-child(7) { grid-column-start: x - 3 } :nth-child(11) { grid-column-start: x - 4 } /* etc. */Item 1 is logically placed in the middle, so if our grid contains N columns, then x is equal to N/2:
:nth-child(1) { grid-column-start: N/2 - 0 } :nth-child(2) { grid-column-start: N/2 - 1 } :nth-child(4) { grid-column-start: N/2 - 2 } :nth-child(7) { grid-column-start: N/2 - 3 } :nth-child(11){ grid-column-start: N/2 - 4 }And since each item spans two columns, N/2 can also be seen as the number of items that can fit within the container. So, let’s update our logic and consider N to be the number of items instead of the number of columns.
:nth-child(1) { grid-column-start: N - 0 } :nth-child(2) { grid-column-start: N - 1 } :nth-child(4) { grid-column-start: N - 2 } :nth-child(7) { grid-column-start: N - 3 } :nth-child(11){ grid-column-start: N - 4 } /* etc. */To calculate the number of items, I will use the same formula as in the previous article:
N = round(down, (container_size + gap)/ (item_size + gap));The only difference is that the size of an item is no longer var(--s)but 2*var(--s) + var(--g), which gives us the following CSS:
.container { --s: 40px; /* size */ --g: 5px; /* gap */ container-type: inline-size; /* we make it a container to use 100cqw */ } .container > * { --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g)))); } .container > *:nth-child(1) { grid-column-start: calc(var(--_n) - 0) } .container > *:nth-child(2) { grid-column-start: calc(var(--_n) - 1) } .container > *:nth-child(4) { grid-column-start: calc(var(--_n) - 2) } .container > *:nth-child(7) { grid-column-start: calc(var(--_n) - 3) } .container > *:nth-child(11){ grid-column-start: calc(var(--_n) - 4) } /* etc. */ CodePen Embed FallbackIt works! We have our pyramidal structure. It’s not yet responsive, but we will get there. By the way, if your goal is to build such a structure with a fixed number of items, and you don’t need responsive behavior, then the above is perfect and you’re done!
How come all the items are correctly placed? We only defined the column for a few items, and we didn’t specify any row!
That’s the power of the auto-placement algorithm of CSS Grid. When you define the column for an item, the next one will be automatically placed after it! We don’t need to manually specify a bunch of columns and rows for all the items.
Improving the ImplementationYou don’t like those verbose :nth-child() selectors, right? Me too, so let’s remove them and have a better implementation. Such a pyramid is well known in the math world, and we have something called a triangular number that I am going to use. Don’t worry, I will not start a math course, so here is the formula I will be using:
j*(j + 1)/2 + 1 = index…where j is a positive integer (zero included).
In theory, all the :nth-child can be generated using the following pseudo code:
for(j = 0; j< ?? ;j++) { :nth-child(j*(j + 1)/2 + 1) { grid-column-start: N - j } }We don’t have loops in CSS, so I will follow the same logic I did in the previous article (which I hope you read, otherwise you will get a bit lost). I express j using the index. I solved the previous formula, which is a quadratic equation, but I am sure you don’t want to get into all that math.
j = sqrt(2*index - 1.75) - .5We can get the index using the sibling-index() function. The logic is to test for each item if sqrt(2*index - 1.75) - .5 is a positive integer.
.container { --s: 40px; /* size */ --g: 5px; /* gap */ container-type: inline-size; /* we make it a container to use 100cqw */ } .container > * { --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g)))); --_j: calc(sqrt(2*sibling-index() - 1.75) - .5); --_d: mod(var(--_j),1); grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j));); }When the --_d variable is equal to 0, it means that --_j is an integer; and when that’s the case I set the column to N - j. I don’t need to test if --_j is positive because it’s always positive. The smallest index value is 1, so the smallest value of --_j is 0.
CodePen Embed FallbackTada! We replaced all the :nth-child() selectors with three lines of CSS that cover any number of items. Now let’s make it responsive!
The Responsive BehaviorBack in my 2021 article, I switched between the pyramidal grid and the classic grid based on screen size. I will do something different this time. I will keep building the pyramid until it’s no longer possible, and from there, it will turn into the classic grid.
Items 1 to 28 form the pyramid. After that, we get the same classic grid we built in the previous article. We need to target the first items of some rows (29, 42, etc.) and shift them. We are not going to set a margin on the left this time, but we do need to set their grid-column-start value to 2.
As usual, we identify the formula of the items, express it using the index, and then test if the result is a positive integer or not:
N*i + (N - 1)*(i - 1) + 1 + N*(N - 1)/2 = indexSo:
i = (index - 2 + N*(3 - N)/2)/(2*N - 1)When i is a positive integer (zero excluded), we set the column start to 2.
.container { --s: 40px; /* size */ --g: 5px; /* gap */ container-type: inline-size; /* we make it a container to use 100cqw */ } .container > * { --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g)))); /* code for the pyramidal grid */ --_j: calc(sqrt(2*sibling-index() - 1.75) - .5); --_d: mod(var(--_j),1); grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j));); /* code for the responsive grid */ --_i: calc((sibling-index() - 2 + (var(--_n)*(3 - var(--_n)))/2)/(2*var(--_n) - 1)); --_c: mod(var(--_i),1); grid-column-start: if(style((--_i > 0) and (--_c: 0)): 2;); }Unlike the --_j variable, I need to test if --_i is a positive value, as it can be negative for some index values. For this reason, I have an extra condition compared to the first one.
But wait! That’s no good at all. We are declaring grid-column-start twice, so only one of them will get used. We should have only one declaration, and for that, we can combine both conditions using a single if() statement:
grid-column-start: if( style((--_i > 0) and (--_c: 0)): 2; /* first condition */ style(--_d: 0): calc(var(--_n) - var(--_j)); /* second condition */ );If the first condition is true (the responsive grid), we set the value to 2; else if the second condition is true (the pyramidal grid), we set the value to calc(var(--_n) - var(--_j)); else we do nothing.
Why that particular order?
Because the responsive grid should have a higher priority. Check the figure below:
Item 29 is part of the pyramidal grid since it’s the first item in its row. This means that the pyramidal condition will always be true for that item. But when the grid becomes responsive, that item becomes part of the responsive grid, and the other condition is also true. When both conditions are true, the responsive condition one should win; that’s why it’s the first condition we test.
Let’s see this in play:
CodePen Embed FallbackOops! The pyramid looks good, but after that, things get messy.
To understand what is happening, let’s look specifically at item 37. If you check the previous figure, you will notice it’s part of the pyramidal structure. So, even if the grid becomes responsive, its condition is still true and it gets a column value from the formula calc(var(--_n) - var(--_j)) which is not good because we want to keep its default value for auto-placement. That’s the case for many items, so we need to fix them.
To find the fix, let’s see how the values in the pyramid behave. They all follow the formula N - j, where j is a positive integer. If, for example, N is equal to 10 we get:
10, 9, 8, 7, ... ,0, -1 , -2At certain points, the values become negative, and since negative values are valid, those items will be randomly placed, disrupting the grid. We need to ensure the negative values are ignored, and the default value is used instead.
We use the following to keep only the positive value and transform all the negative ones into zeroes:
max(0, var(--_n) - var(--_j))We set 0 as a minimum boundary (more on that here) and the values become:
10, 9, 8, 7, ... , 0, 0, 0, 0We either get a positive value for the column or we get 0.
But you said the value should be the default one and not 0.
Yes, but 0 is an invalid value for grid-column-start, so using 0 means the browser will ignore it and fall back to the default value!
Our new code is:
grid-column-start: if( style((--_i > 0) and (--_c: 0)): 2; /* first condition */ style(--_d: 0): max(0,var(--_n) - var(--_j)); /* second condition */ );And it works!
CodePen Embed FallbackYou can add as many items as you want, resize the screen, and everything will fit perfectly!
More ExamplesEnough code and math! Let’s enjoy more variations using different shapes. I’ll let you dissect the code as homework.
Rhombus grid CodePen Embed FallbackYou will notice a slightly different approach for setting the gap between the elements in the next three demos.
Octagon grid CodePen Embed Fallback Circle grid CodePen Embed FallbackAnd the other hexagon grid:
CodePen Embed Fallback ConclusionDo you remember when I told you that we were one property away from completing the grid? That one property (grid-column-start) took us literally the whole article to discuss! This demonstrates that CSS has evolved and requires a new mindset to work with. CSS is no longer a language where you simply set static values such color: red, margin: 10px, display: flex, etc.
Now we can define dynamic behaviors through complex calculations. It’s a whole process of thinking, finding formulas, defining variables, creating conditions, and so on. That’s not something new since I was able to do the same in 2021. However, we now have stronger features that allow us to have less hacky code and more flexible implementations.
Making a Responsive Pyramidal Grid With Modern CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Approximating contrast-color() With Other CSS Features
You have an element with a configurable background color, and you’d like to calculate whether the foreground text should be light or dark. Seems easy enough, especially knowing how mindful we ought to be with accessibility.
There have been a few drafts of a specification function for this functionality, most recently, contrast-color() (formerly color-contrast()) in the CSS Color Module Level 5 draft. But with Safari and Firefox being the only browsers that have implemented it so far, the final version of this functionality is likely still a ways off. There has been a lot of functionality added to CSS in the meantime; enough that I wanted to see whether we could implement it in a cross-browser friendly way today. Here’s what I have:
color: oklch(from <your color> round(1.21 - L) 0 0);Let me explain how I got here.
WCAG 2.2WCAG provides the formulas it uses for calculating the contrast between two RGB colors and Stacie Arellano has described in great detail. It’s based on older methods, calculating the luminance of colors (how perceptually bright they appear) and even tries to clamp for the limitations of monitors and screen flare:
L1 + 0.05 / L2 + 0.05…where the lighter color (L1) is on the top. Luminance ranges from 0 to 1, and this fraction is responsible for contrast ratios going from 1 (1.05/1.05) to 21 (1.05/.05).
The formulas for calculating the luminance of RGB colors are even messier, but I’m only trying to determine whether white or black will have higher contrast with a given color, and can get away with simplifying a little bit. We end up with something like this:
L = 0.1910(R/255+0.055)^2.4 + 0.6426(G/255+0.055)^2.4 + 0.0649(B/255+0.055)^2.4Which we can convert into CSS like this:
calc(.1910*pow(r/255 + .055,2.4)+.6426*pow(g/255 + .055,2.4)+.0649*pow(b/255 + .055,2.4))We can make this whole thing round to 1 or 0 using round(), 1 for white and 0 for black:
round(.67913 - .1910*pow(r/255 + .055, 2.4) - .6426*pow(g/255 + .055, 2.4) - .0649*pow(b/255 + .055, 2.4))Let’s multiply that by 255 and use it for all three channels with the relative color syntax. We end up with this:
color: rgb(from <your color> round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255) round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255) round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255) ); CodePen Embed FallbackA formula that, given a color, returns white or black based on WCAG 2. It’s not easy to read, but it works… except APCA is poised to replace it as a newer, better formula in future WCAG guidelines. We can do the math again, though APCA is an even more complicated formula. We could leverage CSS functions to clean it up a little, but ultimately this implementation is going to be inaccessible, hard to read, and difficult to maintain.
New ApproachI took a step back and thought about what else we have available. We do have another new feature we can try out: color spaces. The “L*” value in the CIELAB color space represents perceptual lightness. It is meant to reflect what our eyes can see. It’s not the same as luminance, but it’s close. Maybe we could guess whether to use black or white for better contrast based on perceptual lightness; let’s see if we can find a number where any color with lower lightness we use black, and higher lightness we use white.
You might instinctively think it should be 50% or .5, but it isn’t. A lot of colors, even when they’re bright, still contrast better with white than black. Here’s some examples using lch(), slowly increasing the lightness while keeping the hue the same:
CodePen Embed FallbackThe transition point where it’s easier to read the black text than white usually happens between 60-65. So, I put together a quick Node app using Colorjs.io to calculate where the cut off should be, using APCA for calculating contrast.
For oklch(), I found the threshold to be between .65 and .72, with an average of .69.
In other words:
- When the OKLCH lightness is .72 or above, black will always contrast better than white.
- Below .65, white will always contrast better than black.
- Between .65 and .72, typically both black and white have contrasts between 45-60.
So, just using round() and the upper bound of .72, we can make a new, shorter implementation:
color: oklch(from <your color> round(1.21 - L) 0 0); CodePen Embed FallbackIf you’re wondering where 1.21 came from, it’s so that .72 rounds down and .71 rounds up: 1.21 - .72 = .49 rounds down, and 1.21 - .71 = .5 rounds up.
This formula works pretty well, having put a couple iterations of this formula into production. It’s easier to read and maintain. That said, this formula more closely matches APCA than WCAG, so sometimes it disagrees with WCAG. For example, WCAG says black has a higher contrast (4.70 than white at 4.3) when placed on #407ac2, whereas APCA says the opposite: black has a contrast of 33.9, and white has a contrast of 75.7. The new CSS formula matches APCA and shows white:
Arguably, this formula may do a better job than WCAG 2.0 because it more closely matches APCA. That said, you’ll still need to check accessibility, and if you’re held legally to WCAG instead of APCA, then maybe this newer simpler formula is less helpful to you.
LCH vs. OKLCHI did run the numbers for both, and aside from OKLCH being designed to be a better replacement for LCH, I also found that the numbers support that OKLCH is a better choice.
With LCH, the gap between too dark for black and too light for white is often bigger, and the gap moves around more. For example, #e862e5 through #fd76f9 are too dark for black and too light for white. With LCH, that runs between lightness 63 through 70; for OKLCH, it’s .7 through .77. The scaling of OKLCH lightness just better matches APCA.
One Step FurtherWhile “most-contrast” will certainly be better, we can implement one more trick. Our current logic simply gives us white or black (which is what the color-contrast() function is currently limited to), but we can change this to give us white or another given color. So, for example, white or the base text color. Starting with this:
color: oklch(from <your color> round(1.21 - L) 0 0); /* becomes: */ --white-or-black: oklch(from <your color> round(1.21 - L) 0 0); color: rgb( from color-mix(in srgb, var(--white-or-black), <base color>) calc(2*r) calc(2*g) calc(2*b) ); CodePen Embed FallbackIt’s some clever math, but it isn’t pleasant to read:
- If --white-or-black is white, color-mix() results in rgb(127.5, 127.5, 127.5) or brighter; doubled we’re at rgb(255, 255, 255) or higher, which is just white.
- If --white-or-black is black, color-mix() cuts the value of each RGB channel by 50%; doubled we’re back to the original value of the <base color>.
Unfortunately, this formula doesn’t work in Safari 18 and below, so you need to target Chrome, Safari 18+ and Firefox. However, it does give us a way with pure CSS to switch between white and a base text color, instead of white and black alone, and we can fallback to white and black in Safari <18.
You can also rewrite these both using CSS Custom Functions, but those aren’t supported everywhere yet either:
@function --white-black(--color) { result: oklch(from var(--color) round(1.21 - l) 0 0); } @function --white-or-base(--color, --base) { result: rgb(from color-mix(in srgb, --white-black(var(--color)), var(--base)) calc(2*r) calc(2*g) calc(2*b)); } CodePen Embed Fallback ConclusionI hope this technique works well for you, and I’d like to reiterate that the point of this approach — looking for a threshold and a simple formula — is to make the implementation flexible and easy to adapt to your needs. You can easily adjust the threshold to whatever works best for you.
Approximating contrast-color() With Other CSS Features originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Trying to Make the Perfect Pie Chart in CSS
Speaking of charts… When was the last time you had to use a pie chart? If you are one of those people who have to give presentations right and left, then congratulations! You are both in my personal hell… and also surrounded by pie charts. Luckily, I think I haven’t needed to use them in ages, or at least that was until recently.
Last year, I volunteered to make ta webpage for a kids’ charity in México1. Everything was pretty standard, but the staff wanted some data displayed as pie charts on their landing page. They didn’t give us a lot of time, so I admit I took the easy route and used one of the many JavaScript libraries out there for making charts.
It looked good, but deep down I felt dirty; pulling in a whole library for a couple of simple pie charts. Feels like the easy way out rather than crafting a real solution.
I want to amend that. In this article, we’ll try making the perfect pie chart in CSS. That means avoiding as much JavaScript as possible while addressing major headaches that comes with handwriting pie charts. But first, let’s set some goals that our “perfect” should comply with.
In order of priority:
- This must be semantic! Meaning a screen reader should be able to understand the data shown in the pie chart.
- This should be HTML-customizable! Once the CSS is done, we only have to change the markup to customize the pie chart.
- This should keep JavaScript to a minimum! No problem with JavaScript in general, it’s just more fun this way.
Once we are done, we should get a pie chart like this one:
Is this too much to ask? Maybe, but we’ll try it anyways.
Conic gradients suck aren’t the bestWe can’t talk about pie charts without talking first about conic gradients. If you’ve read anything related to the conic-gradient() function, then you’ve likely seen that they can be used to create simple pie charts in CSS. Heck, even I have said so in the almanac entry. Why not? If only with one element and a single line of CSS…
.gradient { background: conic-gradient(blue 0% 12.5%, lightblue 12.5% 50%, navy 50% 100%); }We can have seemlessly perfect pie chart:
CodePen Embed FallbackHowever, this method blatantly breaks our first goal of semantic pie charts. As it’s later noted on the same entry:
Do not use the conic-gradient() function to create a real pie chart, or any other infographics for that matter. They don’t hold any semantic meaning and should only be used decoratively.
Remember that gradients are images, so displaying a gradient as a background-image doesn’t tell screen readers anything about the pie charts themselves; they only see an empty element.
This also breaks our second rule of making pie charts HTML-customizable, since for each pie chart we’d have to change its corresponding CSS.
So should we ditch conic-gradient() altogether? As much as I’d like to, its syntax is too good to pass so let’s at least try to up its shortcomings and see where that takes us.
Improving semanticsThe first and most dramatic problem with conic-gradient() is its semantics. We want a rich markup with all the data laid out so it can be understood by screen readers. I must admit I don’t know the best way to semantically write that, but after testing with NVDA, I believe this is a good enough markup for the task:
<figure> <figcaption>Candies sold last month</figcaption> <ul class="pie-chart"> <li data-percentage="35" data-color="#ff6666"><strong>Chocolates</strong></li> <li data-percentage="25" data-color="#4fff66"><strong>Gummies</strong></li> <li data-percentage="25" data-color="#66ffff"><strong>Hard Candy</strong></li> <li data-percentage="15" data-color="#b366ff"><strong>Bubble Gum</strong></li> </ul> </figure>Ideally, this is all we need for our pie chart, and once styles are done, just editing the data-* attributes or adding new <li> elements should update our pie chart.
Just one thing though: In its current state, the data-percentage attribute won’t be read out loud by screen readers, so we’ll have to append it to the end of each item as a pseudo-element. Just remember to add the “%” at the end so it also gets read:
.pie-chart li::after { content: attr(data-percentage) "%"; } CodePen Embed FallbackSo, is it accessible? It is, at least when testing in NVDA. Here it is in Windows:
You may have some questions regarding why I chose this or that. If you trust me, let’s keep going, but if not, here is my thought process:
Why use data-attributes instead of writing each percentage directly?We could easily write them inside each <li>, but using attributes we can get each percentage on CSS through the attr() function. And as we’ll see later it makes working with CSS a whole lot easier.
Why <figure>?The <figure> element can be used as a self-contained wrapper for our pie chart, and besides images, it’s used a lot for diagrams too. It comes in handy since we can give it a title inside <figcaption> and then write out the data on an unordered list, which I didn’t know was among the content permitted inside <figure> since <ul> is considered flow content.
Why not use ARIA attributes?We could have used an aria-description attribute so screen readers can read the corresponding percentage for each item, which is arguably the most important part. However, we may need to visually show the legend, too. That means there is no advantage to having percentages both semantically and visually since they might get read twice: (1) once on the aria-description and (2) again on the pseudo-element.
Making it a pie chartWe have our data on paper. Now it’s time to make it look like an actual pie chart. My first thought was, “This should be easy, with the markup done, we can now use a conic-gradient()!”
Well… I was very wrong, but not because of semantics, but how the CSS Cascade works.
Let’s peek again at the conic-gradient() syntax. If we have the following data:
- Item 1: 15%
- Item 2: 35%
- Item 3: 50%
…then we would write down the following conic-gradient():
.gradient { background: conic-gradient( blue 0% 15%, lightblue 15% 50%, navy 50% 100% ); }This basically says: “Paint the first color from 0 to 15%, the next color from 15% to 50% (so the difference is 35%), and so on.”
Do you see the issue? The pie chart is drawn in a single conic-gradient(), which equals a single element. You may not see it, but that’s terrible! If we want to show each item’s weight inside data-percentage — making everything prettier — then we would need a way to access all these percentages from the parent element. That’s impossible!
The only way we can get away with the simplicity of data-percentage is if each item draws its own slice. This doesn’t mean, however, that we can’t use conic-gradient(), but rather we’ll have to use more than one.
The plan is for each of these items to have their own conic-gradient() painting their slice and then place them all on top of each other:
To do this, we’ll first give each <li> some dimensions. Instead of hardcoding a size, we’ll define a --radius property that’ll come in handy later for keeping our styles maintainable when updating the HTML.
.pie-chart li { --radius: 20vmin; width: calc(var(--radius) * 2); /* radius twice = diameter */ aspect-ratio: 1; border-radius: 50%; }Then, we’ll get the data-percentage attribute into CSS using attr() and its new type syntax that allows us to parse attributes as something other than a string. Just beware that the new syntax is currently limited to Chromium as I’m writing this.
However, in CSS it is far better to work with decimals (like 0.1) instead of percentages (like 10%) because we can multiply them by other units. So we’ll parse the data-percentage attribute as a <number> and then divide it by 100 to get our percentage in decimal form.
.pie-chart li { /* ... */ --weighing: calc(attr(data-percentage type(<number>)) / 100); }We still need it as a percentage, which means multiplying that result by 1%.
.pie-chart li { /* ... */ --percentage: calc(attr(data-percentage type(<number>)) * 1%); }Lastly, we’ll get the data-color attribute from the HTML using attr() again, but with the <color> type this time instead of a <number>:
.pie-chart li { /* ... */ --bg-color: attr(data-color type(<color>)); }Let’s put the --weighing variable aside for now and use our other two variables to create the conic-gradient() slices. These should go from 0% to the desired percentage, and then become transparent afterwards:
.pie-chart li { /* ... */ background: conic-gradient( var(--bg-color) 0% var(--percentage), transparent var(--percentage) 100% ); }I am defining the starting 0% and ending 100% explicitly, but since those are the default values, we could technically remove them.
Here’s where we’re at:
CodePen Embed FallbackPerhaps an image will help if your browser lacks support for the new attr() syntax:
Now that all the slices are done, you’ll notice each of them starts from the top and goes in a clockwise direction. We need to position these, you know, in a pie shape, so our next step is to rotate them appropriately to form a circle.
This is when we hit a problem: the amount each slice rotates depends on the number of items that precede it. We’ll have to rotate an item by whatever size the slice before it is. It would be ideal to have an accumulator variable (like --accum) that holds the sum of the percentages before each item. However, due to the way the CSS Cascade works, we can neither share state between siblings nor update the variable on each sibling.
And believe me, I tried really hard to work around these issues. But it seems we are forced into two options:
- Hardcode the --accum variable on each <li> element.
- Use JavaScript to calculate the --accum variable.
The choice isn’t that hard if we revisit our goals: hardcoding --accum would negate flexible HTML since moving an item or changing percentages would force us to manually calculate the --accum variable again.
JavaScript, however, makes this a trivial effort:
const pieChartItems = document.querySelectorAll(".pie-chart li"); let accum = 0; pieChartItems.forEach((item) =>; { item.style.setProperty("--accum", accum); accum += parseFloat(item.getAttribute("data-percentage")); });With --accum out of the way, we can rotate each conic-gradient() using the from syntax, that tells the conic gradient the rotation’s starting point. The thing is that it only takes an angle, not a percentage. (I feel like a percentage should also work fine, but that’s a topic for another time).
To work around this, we’ll have to create yet another variable — let’s call it --offset — that is equal to --accum converted to an angle. That way, we can plug the value into each conic-gradient():
.pie-chart li { /* ... */ --offset: calc(360deg * var(--accum) / 100); background: conic-gradient( from var(--offset), var(--bg-color) 0% var(--percentage), transparent var(--percentage) 100% ); }We’re looking a lot better!
CodePen Embed FallbackWhat’s left is to place all items on top of each other. There are plenty of ways to do this, of course, though the easiest might be CSS Grid.
.pie-chart { display: grid; place-items: center; } .pie-chart li { /* ... */ grid-row: 1; grid-column: 1; }This little bit of CSS arranges all of the slices in the dead center of the .pie-chart container, where each slice covers the container’s only row and column. They slices won’t collide because they’re properly rotated!
CodePen Embed FallbackExcept for those overlapping labels, we’re in really, really good shape! Let’s clean that stuff up.
Positioning labelsRight now, the name and percentage labels inside the <figcaption> are splattered on top of one another. We want them floating next to their respective slices. To fix this, let’s start by moving all those items to the center of the .pie-chart container using the same grid-centering trick we we applied on the container itself:
.pie-chart li { /* ... */ display: grid; place-items: center; } .pie-chart li::after, strong { grid-row: 1; grid-column: 1; }Luckily, I’ve already explored how to lay things out in a circle using the newer CSS cos() and sin(). Give those links a read because there’s a lot of context in there. In short, given an angle and a radius, we can use cos() and sin() to get the X and Y coordinates for each item around a circle.
For that, we’ll need — you guessed it! — another CSS variable representing the angle (we’ll call it --theta) where we’ll place each label. We can calculate that angle this next formula:
.pie-chart li { /* ... */ --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg); }It’s worth knowing what that formula is doing:
- 360deg * var(--weighing)) / 2: Gets the percentage as an angle then divides it by two to find the middle point.
- + var(--offset): Moves the angle to match the current offset.
- - 90deg. cos() and sin(): The angles are measured from the right, but conic-gradient() starts from the top. This part corrects each angle by -90deg.
We can find the X and Y coordinates using the --theta and --radius variables, like the following pseudo code:
x = cos(theta) * radius y = sin(theta) * radiusWhich translates to…
.pie-chart li { /* ... */ --pos-x: calc(cos(var(--theta)) * var(--radius)); --pos-y: calc(sin(var(--theta)) * var(--radius)); }This places each item on the pie chart’s edge, so we’ll add in a --gap between them:
.pie-chart li { /* ... */ --gap: 4rem; --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap))); --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap))); }And we’ll translate each label by --pos-x and --pos-y:
.pie-chart li::after, strong { /* ... */ transform: translateX(var(--pos-x)) translateY(var(--pos-y)); }Oh wait, just one more minor detail. The label and percentage for each item are still stacked on top of each other. Luckily, fixing it is as easy as translating the percentage a little more on the Y-axis:
.pie-chart li::after { --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh); }Now we’re cooking with gas!
CodePen Embed FallbackLet’s make sure this is screenreader-friendly:
That’s about it… for now…I’d call this a really good start toward a “perfect” pie chart, but there are still several things we could improve:
- The pie chart assumes you’ll write the percentages yourself, but there should be a way to input the raw number of items and then calculate their percentages.
- The data-color attribute is fine, but if it isn’t provided, we should still provide a way to let CSS generate the colors. Perhaps a good job for color-mix()?
- What about different types of charts? Bar charts, anyone?
- This is sorta screaming for a nice hover effect, like maybe scaling a slice and revealing it?
That’s all I could come up with for now, but I’m already planning to chip away at those at follow up with another piece (get it?!). Also, nothing is perfect without lots of feedback, so let me know what you would change or add to this pie chart so it can be truly perfect!
1 They are great people helping kids through extremely difficult times, so if you are interested in donating, you can find more on their socials. ↪️
Trying to Make the Perfect Pie Chart in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
CSS Bar Charts Using Modern Functions
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 FallbackYes, that was easy to do, but it’s best to know exactly why it works. So, let’s break that down.
Automatically Establishing Grid ColumnsDeclaring 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 RowsIt’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 46The 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 FallbackOr we can skip bars altogether and use markers instead:
CodePen Embed Fallback CodePen Embed FallbackWe can also swap the columns and rows for horizontal bar charts:
CodePen Embed Fallback Wrapping upPretty 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():
- Making Charts with CSS (Robin Rendle, 2015)
- Making A Bar Chart with CSS Grid (Robin Rendle, 2017)
- More CSS Charts, with Grid & Custom Properties (Miriam Suzanne, 2017)
- Overlapping Bar Charts (Saleh Mubasher, 2022)
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
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:
- Visual Studio Marketplace for VS Code users
- 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 themeWhen 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 😉
{ "keywords": [ "theme", "dark theme", "twilight", "cosmos", "color-theme", "dark", "purple", "blue", "vscode-theme" ], } Publishing to Visual Studio MarketplaceMicrosoft 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 VSCEFor 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 publishAnd you’re done!
Publishing manuallyYou’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 CodeIf 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 VSXThanks 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 namespaceThis 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 themeIt 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:
 Wrapping upIt 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
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 simpleI 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 scopesThankfully, 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:
- I said I wanted to make a custom theme.
- I told it to create a scaffold for me.
- 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 locallyIt 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 scopesAt 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:
- You can use any part of the available scopes. variable, variable.prop, and variable.prop.css all work.
- You can increase specificity by stating more properties. variable.prop.css > variable.prop > variable in terms of specificity.
- The higher scope is more specific than the lower one. variable > meta.function.misc.css.
- You can other scopes with them like CSS selectors if you need to overwrite a higher scope. meta.function variable > variable
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:
- Creating a VS Code Theme by Sarah Drasner
- Everyone is getting syntax highlighting wrong by Tonsky
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:
- How do I guide my eyes?
- What are important elements that I have to see/know?
- 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.
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 colorsVS 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 upThe 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:
- Visual Studio Marketplace if you use VS Code
- Open VSX if you use Cursor or other editors
- npm if you wanna use the for Shiki
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
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 transitionsChris 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 queriesKevin 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 resetI 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 workIf 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 worksIn 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 cornerSpeaking 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 syntaxIf 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 CanaryYou 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.
Agent Orchestration UI
Quite quickly, AI products have transitioned from models behind the scenes powering features to people talking directly to models (chat) to models deciding which tools to use and how (agents) to agents orchestrating other agents. Like the shifts that came before it, orchestration is a another opportunity for new AI products and UI solutions.
I charted the transition from AI models behind the scenes to chat to agents last year in The Evolution of AI Products. At the time, we were wrestling with how to spin up sub-agents and run them in the background. That's mostly been settled and agent orchestration (coordinating and verifying the work of multiple agents on unified tasks) is today's AI product design challenge.
As Microsoft CEO, Satya Nadella put it:
"One of the metaphors I think we're all sort of working towards is 'I do this macro delegation and micro steering [of AI agents]'. What is the UI that meets this new intelligence capability? It's just a different way than the chat interface. And I think that would be a new way for the human computer interface. Quite frankly, it's probably bigger."He's right. When you have multiple agents working together, you need more than a conversation thread as anyone that's tried to manage a team through a single Slack or email thread can attest.
Introducing IntentIntent by Augment (in early preview today) is a new software development app with agent orchestration at its core. You're not managing individual model calls or chat threads. You're setting up workspaces, defining your intent (what you want to get done), and letting specialized agents work in parallel while staying aligned.
To ground this in a real-world analogy, if you want to accomplish a large or complicated task you need...
- A team of the right people for the job, often specialists
- To give the team the information they need to complete the job
- The right environment where the team can coordinate and work safely
That's a space in Intent in a nutshell. Software developers create a new space for every task they want to get done. Each space makes use of specific agents and context to complete the task. Each space is isolated using git worktrees so agents can work freely and safely. Fire up as many spaces as you want without having them interfere with each other.
I've often said "context is king" when talking about what makes AI products effective. That's especially true when you need to coordinate the work of multiple parallel agents with varying capabilities. In Intent, context is managed by a living spec which provides a shared understanding that multiple agents can reference while working on different parts of a problem. This living spec is written and updated by a coordinator agent as it manages the work of implementer and verifier agents. It's a whole agent dev team.
Because agents operate from the same spec, parallel work becomes possible. Assumptions, tradeoffs, and decisions stay aligned and updated as code changes without requiring constant human intervention to keep things on the same page. For instance, one agent handles the theme system while another works on component styles. Both reference the same context, so their work fits together.
By default, a coordinator writes a spec and delegates to specialists for you. But you can also set up spaces with custom agents and manage your own context if you want. Think of it as manual vs. auto mode.
The UI for agent orchestration in Intent isn't a fancier chat interface. It's context management, agent specialization, and a unified developer workflow. It's not hard to squint and see very similar orchestration UI being useful for lots of other domains too.
Styling ::search-text and Other Highlight-y Pseudo-Elements
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 onlyAnd 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 FallbackYour 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
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…
🫣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
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 youInstead 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. 😉
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
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 FallbackI 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 FallbackThe 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 ShapeLet’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 FallbackThe 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 FallbackThere 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 GridNow 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 FallbackNothing 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 FallbackThe 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 FallbackIt’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 FallbackShould 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 ExamplesThe 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 FallbackA 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 FallbackAlmost! 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 FallbackFrom there, we can also get another type of hexagon grid:
CodePen Embed FallbackAnd why not a grid of circles as well? Here we go:
CodePen Embed FallbackAs 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.
ConclusionI will end this article with another demo that will serve as a small homework for you:
CodePen Embed FallbackThis 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
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.enabledI 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.
Design Tools Are The New Design Deliverables
Design projects used to end when "final" assets were sent over to a client. If more assets were needed, the client would work with the same designer again or use brand guidelines to guide the work of others. But with today's AI software development tools, there's a third option: custom tools that create assets on demand, with brand guidelines encoded directly in.
For decades, designers delivered fixed assets. A project meant a set number of ads, illustrations, mockups, icons. When the client needed more, they came back to the designer and waited. To help others create on-brand assets without that bottleneck, designers crafted brand guidelines: documents that spelled out what could and couldn't be done with colors, typography, imagery, and layout.
But with today's AI coding agents, building software is remarkably easy. So instead of handing over static assets and static guidelines, designers can deliver custom software. Tools that let clients create their own on-brand assets whenever they need them.
This is something I've wanted to build ever since I started using AI image generators within Google years ago. I tried: LoRAs, ControlNet, IP-Adapter, character sheets. None of it worked well enough to consistently render assets the right way. Until now.
LukeW Character MakerSince the late nineties, I've used a green avatar to represent the LukeW brand: big green head, green shirt, green trousers, and a distinct flat yet slightly rendered style. So to illustrate the idea of design tools as deliverables, I build a site that creates on-brand variations of this character.
The LukeW Character Maker allows people to create custom LukeW characters while enforcing brand guidelines: specific colors, illustration style, format, and guardrails on what can and can't be generated. Have fun trying it yourself.
How It WorksSince most people will ask... a few words on how it works. A highly capable image model is critical. I've had good results using both Reve and Google's Nano Banana but there's more to it than just picking an image model.
People's asset creation requests are analyzed and rewritten by a large language model that makes sure the request aligns with brand style and guidelines. Each generation also includes multiple reference images as context to keep things on rails. And last but least, there's a verification step that checks results and fixes things when necessary. For instance, Google's image generation API ignores reference images about 10-20% of the time. The validation step checks when that's happening and re-renders images when needed. Oh, and I built and integrated the software using Augment Code.
The LukeW Character Maker is a small (but for me, exciting) example of what design deliverables can be today. Not just guidelines. Not just assets. But Tools.
HTTP Archive 2025 Web Almanac
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
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.