Jump to content

Welcome to CodeNameJessica

โœจ Welcome to CodeNameJessica! โœจ

๐Ÿ’ป Where tech meets community.

Hello, Guest! ๐Ÿ‘‹
You're just a few clicks away from joining an exclusive space for tech enthusiasts, problem-solvers, and lifelong learners like you.

๐Ÿ” Why Join?
By becoming a member of CodeNameJessica, youโ€™ll get access to:
โœ… In-depth discussions on Linux, Security, Server Administration, Programming, and more
โœ… Exclusive resources, tools, and scripts for IT professionals
โœ… A supportive community of like-minded individuals to share ideas, solve problems, and learn together
โœ… Project showcases, guides, and tutorials from our members
โœ… Personalized profiles and direct messaging to collaborate with other techies

๐ŸŒ Sign Up Now and Unlock Full Access!
As a guest, you're seeing just a glimpse of what we offer. Don't miss out on the complete experience! Create a free account today and start exploring everything CodeNameJessica has to offer.

  • Entries

    207
  • Comments

    0
  • Views

    4157

Entries in this blog

by: Chris Coyier
Mon, 16 Jun 2025 16:23:56 +0000


First a quick heads up about… me. I have a weird itch to do “streaming”, so I’m letting myself just be a hardcore beginner and giving it a shot. The plan is just hang out with whoever shows up and make stuff and talk about front end web development and design. So:

Seems like those two platforms make the most sense for that, so here we go.

I made this super sick banner for Twitch, which you can’t even see because it’s covered by UI stuff lol.

Welp.

I suppose you knew that there’s no way I’m letting “liquid glass” slide by this week. Or should I say:

Amazing.

Marie actually beat me to it doing a whole Spark issue on it last week. Obviously CodePen users are all over this design trend, as it’s an absolutely magnetic challenge in CSS. Kevin Powell did a video which happened to drop at the perfect time. Kevin is so good at these things I’m like sick with jealousy about it. Maybe my stream will level up my video teaching skills.

It’s not like CodePen is only now starting to have these glass-like effects. People have been doing it for ages. It had a particular boon when backdrop-filter: blur(2px); became a thing โ€” that’s more like “frosted” glass โ€” but still, Apple is doing that, too. Maybe -webkit-box-reflect will get new life on the web also? Feels related.

Sebastiaan de With fortold it nearly perfectly well. ๐Ÿ‘๐Ÿ‘๐Ÿ‘. Little touches like the reflective progress bar are so cool.


I don’t know if Apple is actually doing this particular detail, I don’t have the new OS yet, but Sebastiaan’s idea is awesome.

Apple is actually quite serious about this, and released a video of the whole idea. Honestly I think it’s kinda awesome looking.

But I did kinda ๐Ÿ˜ฌ about the accessibility of it.

No chance the text “Nao” above is passing any contrast test. Nao way amiright?

Feels like text/background contrast has taken a hit. I haven’t seen a full throated takedown of it yet (there are some mentions though), but I imagine that’s coming. There are already settings in there to tone the effects down, I hear.

I thought out loud the other month: literally everything ships inaccessibly. And since having that thought I’ve seen a half dozen things ship that way. Certainly we’re not immune to it, but it’s good motivation to get some more accessibility testing done (we’ve done a good bit already!) on our new editor before it goes out.

Random thing before I sign off. The Oatmeal on Erasers is lovely.

by: Zell Liew
Mon, 16 Jun 2025 12:47:51 +0000


Resize Observer, Mutation Observer, and Intersection Observers are all good APIs that are more performant than their older counterparts:

The API for these three observers are quite similar (but they have their differences which we will go into later). To use an observer, you have to follow the steps below:

  1. Create a new observer with the new keyword: This observer takes in an observer function to execute.
  2. Do something with the observed changes: This is done via the observer function that is passed into the observer.
  3. Observe a specific element: By using the observe method.
  4. (Optionally) unobserve the element: By using the unobserve or disconnect method. (depending on which observer you’re using).

In practice, the above steps looks like this with the ResizeObserver.

// Step 1: Create a new observer
const observer = new ResizeObserver(observerFn)

// Step 2: Do something with the observed changes
function observerFn (entries) {
  for (let entry of entries) {
    // Do something with entry
  }
}

// Step 3: Observe an element
const element = document.querySelector('#some-element')
observer.observe(element);

// Step 4 (optional): Disconnect the observer
observer.disconnect(element)

This looks clear (and understandable) after the steps have been made clear. But it can look like a mess without the comments:

const observer = new ResizeObserver(observerFn)

function observerFn (entries) {
  for (let entry of entries) {
    // Do something with entry
  }
}

const element = document.querySelector('#some-element')
observer.observe(element);

The good news is: I think we can improve the observer APIs and make them easier to use.

The Resize Observer

Let’s start with the ResizeObserver since it’s the simplest of them all. We’ll begin by writing a function that encapsulates the resizeObserver that we create.

function resizeObserver () {
  // ... Do something
}

The easiest way to begin refactoring the ResizeObserver code is to put everything we’ve created into our resizeObserver first.

function resizeObserver () {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      // Do something with entry
    }
  }

  const node = document.querySelector('#some-element')
  observer.observe(node);
}

Next, we can pass the element into the function to make it simpler. When we do this, we can eliminate the document.querySelector line.

function resizeObserver (element) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      // Do something with entry
    }
  }

  observer.observe(node);
}

This makes the function more versatile since we can now pass any element into it.

// Usage of the resizeObserver function
const node = document.querySelector('#some-element')
const obs = resizeObserver(node)

This is already much easier than writing all of the ResizeObserver code from scratch whenever you wish to use it.

Next, it’s quite obvious that we have to pass in an observer function to the callback. So, we can potentially do this:

// Not great
function resizeObserver (node, observerFn) {
  const observer = new ResizeObserver(observerFn)
  observer.observe(node);
}

Since observerFn is always the same โ€” it loops through the entries and acts on every entry โ€” we could keep the observerFn and pass in a callback to perform tasks when the element is resized.

// Better 
function resizeObserver (node, callback) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      callback(entry)
    }
  }

  observer.observe(node);
}

To use this, we can pass callback into the resizeObserver โ€” this makes resizeObserver operate somewhat like an event listener which we are already familiar with.

// Usage of the resizeObserver function
const node = document.querySelector('#some-element')
const obs = resizeObserver(node, entry => {
  // Do something with each entry
})

We can make the callback slightly better by providing both entry and entries. There’s no performance hit for passing an additional variable so there’s no harm providing more flexibility here.

function resizeObserver (element, callback) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      callback({ entry, entries })
    }
  }

  observer.observe(element);
}

Then we can grab entries in the callback if we need to.

// Usage of the resizeObserver function
// ...
const obs = resizeObserver(node, ({ entry, entries }) => {
  // ...
})

Next, it makes sense to pass the callback as an option parameter instead of a variable. This will make resizeObserver more consistent with the mutationObserver and intersectionObserver functions that we will create in the next article.

function resizeObserver (element, options = {}) {
  const { callback } = options
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
        callback({ entry, entries })
      }
  }

  observer.observe(element);
}

Then we can use resizeObserver like this.

const obs = resizeObserver(node, {
  callback ({ entry, entries }) {
    // Do something ...
  }
})

The observer can take in an option too

ResizeObserver‘s observe method can take in an options object that contains one property, box. This determines whether the observer will observe changes to content-box, border-box or device-pixel-content-box.

So, we need to extract these options from the options object and pass them to observe.

function resizeObserver (element, options = {}) {
  const { callback, ...opts } = options
  // ...
  observer.observe(element, opts);
}

Optional: Event listener pattern

I prefer using callback because it’s quite straightforward. But if you want to use a standard event listener pattern, we can do that, too. The trick here is to emit an event. We’ll call it resize-obs since resize is already taken.

function resizeObserver (element, options = {}) {
  // ...
  function observerFn (entries) {
    for (let entry of entries) {
      if (callback) callback({ entry, entries })
      else {
        node.dispatchEvent(
          new CustomEvent('resize-obs', {
            detail: { entry, entries },
          }),
        )
      }
    }
  }

  // ...
}

Then we can listen to the resize-obs event, like this:

const obs = resizeObserver(node)
node.addEventListener('resize-obs', event => {
  const { entry, entries } = event.detail
})

Again, this is optional.

Unobserving the element

One final step is to allow the user to stop observing the element(s) when observation is no longer required. To do this, we can return two of the observer methods:

  • unobserve: Stops observing one Element
  • disconnect: Stops observing all Elements
function resizeObserver (node, options = {}) {
  // ...
  return {
    unobserve(node) {
      observer.unobserve(node)
    },
    
    disconnect() {
      observer.disconnet()
    }
  }
}

Both methods do the same thing for what we have built so far since we only allowed resizeObserver to observe one element. So, pick whatever method you prefer to stop observing the element.

const obs = resizeObserver(node, {
  callback ({ entry, entries }) {
    // Do something ...
  }
})

// Stops observing all elements 
obs.disconect()

With this, we’ve completed the creation of a better API for the ResizeObserver โ€” the resizeObserver function.

Code snippet

Here’s the code we’ve wrote for resizeObserver

export function resizeObserver(node, options = {}) {
  const observer = new ResizeObserver(observerFn)
  const { callback, ...opts } = options

  function observerFn(entries) {
    for (const entry of entries) {
      // Callback pattern
      if (callback) callback({ entry, entries, observer })
      // Event listener pattern
      else {
        node.dispatchEvent(
          new CustomEvent('resize-obs', {
            detail: { entry, entries, observer },
          })
        )
      }
    }
  }
 
  observer.observe(node)

  return {
    unobserve(node) {
      observer.unobserve(node)
    },
    
    disconnect() {
      observer.disconnect()
    }
  }
}

Using this in practice via Splendid Labz

Splendid Labz has a utils library that contains an enhanced version of the resizeObserver we made above. You can use it if you wanna use a enhanced observer, or if you don’t want to copy-paste the observer code into your projects.

import { resizeObserver } from '@splendidlabz/utils/dom'

const node = document.querySelector('.some-element')
const obs = resizeObserver(node, {
  callback ({ entry, entries }) {
    /* Do what you want here */
  }
})

Bonus: The Splendid Labz resizeObserver is capable of observing multiple elements at once. It can also unobserve multiple elements at once.

const items = document.querySelectorAll('.elements')
const obs = resizeObserver(items, {
  callback ({ entry, entries }) {
    /* Do what you want here */
  }
})

// Unobserves two items at once
const subset = [items[0], items[1]]
obs.unobserve(subset) 

Found this refactoring helpful?

Refactoring is ultra useful (and important) because its a process that lets us create code that’s easy to use or maintain.

If you found this refactoring exercise useful, you might just love how I teach JavaScript to budding developers in my Learn JavaScript course.

In this course, you’ll learn to build 20 real-world components. For each component, we start off simple. Then we add features and you’ll learn to refactor along the way.

Thatโ€™s it!

Hope you enjoyed this piece and see you in the next one.


A Better API for the Resize Observer originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Sladjana Stojanovic
Thu, 12 Jun 2025 13:58:38 +0000


For years, I believed that drag-and-drop games โ€” especially those involving rotation, spatial logic, and puzzle solving โ€” were the exclusive domain of JavaScript. Until one day, I asked AI:

“Is it possible to build a fully interactive Tangram puzzle game using only CSS?”

The answer: “No โ€” not really. You’ll need JavaScript.” That was all the motivation I needed to prove otherwise.

But first, letโ€™s ask the obvious question: Why would anyone do this?

Well…

  • To know how far CSS can be pushed in creating interactive UIs.
  • To get better at my CSS skills.
  • And it’s fun!

Fair enough?

Now, hereโ€™s the unsurprising truth: CSS isnโ€™t exactly made for this. Itโ€™s not a logic language, and letโ€™s be honest, itโ€™s not particularly dynamic either. (Sure, we have CSS variables and some handy built-in functions now, hooray!)

In JavaScript, we naturally think in terms of functions, loops, conditions, objects, comparisons. We write logic, abstract things into methods, and eventually ship a bundle that the browser understands. And once itโ€™s shipped? We rarely look at that final JavaScript bundle โ€” we just focus on keeping it lean.

Now ask yourself: isnโ€™t that exactly what Sass does for CSS?

Why should we hand-write endless lines of repetitive CSS when we can use mixins and functions to generate it โ€” cleanly, efficiently, and without caring how many lines it takes, as long as the output is optimized?

So, we put it to the test and it turns out Sass can replace JavaScript, at least when it comes to low-level logic and puzzle behavior. With nothing but maps, mixins, functions, and a whole lot of math, we managed to bring our Tangram puzzle to life, no JavaScript required.

Let the (CSS-only) games begin! 🎉

The game

The game consists of seven pieces: the classic Tangram set. Naturally, these pieces can be arranged into a perfect square (and many other shapes, too). But we need a bit more than just static pieces.

So hereโ€™s what I am building:

  • A puzzle goal, which is the target shape the player has to recreate.
  • A start button that shuffles all the pieces into a staging area.
  • Each piece is clickable and interactive.
  • The puzzle should let the user know when they get a piece wrong and also celebrate when they finish the puzzle.

The HTML structure

I started by setting up the HTML structure, which is no small task, considering the number of elements involved.

  • Each shape was given seven radio buttons. I chose radios over checkboxes to take advantage of their built-in exclusivity. Only one can be selected within the same group. This made it much easier to track which shape and state were currently active.
  • The start button? Also a radio input. A checkbox couldโ€™ve worked too, but for the sake of consistency, I stuck with radios across the board.
  • The puzzle map itself is just a plain old <div>, simple and effective.
  • For rotation, we added eight radio buttons, each representing a 45-degree increment: 45ยฐ, 90ยฐ, 135ยฐ, all the way to 360ยฐ. These simulate rotation controls entirely in CSS.
  • Every potential shadow position got its own radio button too. (Yes, itโ€™s a lot, I know.)
  • And to wrap it all up, I included a classic reset button inside a <form> using <button type="reset">, so players can easily start over at any point.

Given the sheer number of elements required, I used Pug to generate the HTML more efficiently. It was purely a convenience choice. It doesnโ€™t affect the logic or behavior of the puzzle in any way.

Below is a sample of the compiled HTML. It might look overwhelming at first glance (and this is just a portion of it!), but it illustrates the structural complexity involved. This section is collapsed to not nuke your screen, but it can be expanded if you’d like to explore it.

Open HTML Code
<div class="wrapper">
  <div class="tanagram-box"></div>
  <div class="tanagram-box"></div>
  <form class="container">
    <input class="hide_input start" type="checkbox" id="start" autofocus />
    <button class="start-button" type="reset" id="restart">Restart</button>
    <label class="start-button" for="start">Start </label>
    <div class="shadow">
      <input class="hide_input" type="radio" id="blueTriangle-tan" name="tan-active" />
      <input class="hide_input" type="radio" id="yellowTriangle-tan" name="tan-active" />
      <!-- Inputs for others tans -->
      <input class="hide_input" type="radio" id="rotation-reset" name="tan-active" />
      <input class="hide_input" type="radio" id="rotation-45" name="tan-rotation" />
      <input class="hide_input" type="radio" id="rotation-90" name="tan-rotation" />
      <!--radios for 90, 225, 315, 360 -->

      <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-135" name="tan-rotation" />
      <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-225" name="tan-rotation" />
      <!-- radio for every possible shape shadows-->

      <label class="rotation rot" for="rotation-45" id="rot45">⟲</label>
      <label class="rotation rot" for="rotation-90" id="rot90">⟲</label>
      <!--radios for 90, 225, 315, 360 -->
      <label class="rotation" for="rotation-reset" id="rotReset">✘</label>

      <label class="blueTriangle tans" for="blueTriangle-tan" id="tanblueTrianglelab"></label>
      <div class="tans tan_blocked" id="tanblueTrianglelabRes"></div>
      <!-- labels for every tan and disabled div -->

      <label class="blueTriangle tans" for="blueTriangle-tan-1-90" id="tanblueTrianglelab-1-90"></label>
      <label class="blueTriangle tans" for="blueTriangle-tan-1-225" id="tanblueTrianglelab-1-225"></label>
      <!-- labels radio for every possible shape shadows-->
      <div class="shape"></div>
    </div>
  </form>
  <div class="tanagram-box"></div>
  <div class="tanagram-box"></div>
  <div class="tanagram-box"></div>
  <div class="tanagram-box"></div>
  <div class="tanagram-box"></div>
</div>

Creating maps for shape data

Now that HTML skeleton is ready, itโ€™s time to inject it with some real power. Thatโ€™s where our Sass maps come in, and hereโ€™s where the puzzle logic starts to shine.

Note: Maps in Sass hold pairs of keys and values, and make it easy to look up a value by its corresponding key. Like objects in JavaScript, dictionaries in Python and, well, maps in C++.

Iโ€™m mapping out all the core data needed to control each tangram piece (tan): its color, shape, position, and even interaction logic. These maps contain:

  • the background-color for each tan,
  • the clip-path coordinates that define their shapes,
  • the initial position for each tan,
  • the position of the blocking div (which disables interaction when a tan is selected),
  • the shadow positions (coordinates for the tan’s silhouette displayed on the task board),
  • the grid information, and
  • the winning combinations โ€” the exact target coordinates for each tan, marking the correct solution.
$colors: ( blue-color: #53a0e0, yellow-color: #f7db4f, /* Colors for each tan */ );
$nth-child-grid: ( 1: (2, 3, 1, 2, ), 2: ( 3, 4, 1, 2, ), 4: ( 1, 2, 2, 3, ), /* More entries to be added */);
$bluePosiblePositions: ( 45: none, 90: ( (6.7, 11.2), ), 135: none, 180: none, /* Positions defined up to 360 degrees */);
/* Other tans */

/* Data defined for each tan */
$tansShapes: (
  blueTriangle: (
    color: map.get($colors, blue-color),
    clip-path: ( 0 0, 50 50, 0 100, ),
    rot-btn-position: ( -20, -25, ),
    exit-mode-btn-position: ( -20, -33, ),
    tan-position: ( -6, -37, ),
    diable-lab-position: ( -12, -38, ),
    poss-positions: $bluePosiblePositions,
    correct-position: ((4.7, 13.5), (18.8, 13.3), ),
    transform-origin: ( 4.17, 12.5,),
  ),
);

/* Remaining 7 combinations */
$winningCombinations: (
  combo1: (
    (blueTriangle, 1, 360),
    (yellowTriangle, 1, 225),
    (pinkTriangle, 1, 180),
    (redTriangle, 4, 360),
    (purpleTriangle, 2, 225),
    (square, 1, 90),
    (polygon, 4, 90),
  ),
);

You can see this in action on CodePen, where these maps drive the actual look and behavior of each puzzle piece. At this point, there’s no visible change in the preview. We’ve simply prepared and stored the data for later use.

Using mixins to read from maps

The main idea is to create reusable mixins that will read data from the maps and apply it to the corresponding CSS rules when needed.

But before that, weโ€™ve elevated things to a higher level by making one key decision: We never hard-coded units directly inside the maps. Instead, we built a reusable utility function that dynamically adds the desired unit (e.g., vminpx, etc.) to any numeric value when it’s being used. This way, when can use our maps however we please.

@function get-coordinates($data, $key, $separator, $unit) {
  $coordinates: null;

  // Check if the first argument is a map
  @if meta.type-of($data) == "map" {
    // If the map contains the specified key
    @if map.has-key($data, $key) {
      // Get the value associated with the key (expected to be a list of coordinates)
      $coordinates: map.get($data, $key);
    }

  //  If the first argument is a list
  } @else if meta.type-of($data) == "list" {
    // Ensure the key is a valid index (1-based) within the list
    @if meta.type-of($key) == "number" and $key > 0 and $key <= list.length($data) {
      // Retrieve the item at the specified index
      $coordinates: list.nth($data, $key);
    }

  //  If neither map nor list, throw an error
  } @else {
    @error "Invalid input: First argument must be a map or a list.";
  }

  // If no valid coordinates were found, return null
  @if $coordinates == null {
    @return null;
  }

  //  Extract x and y values from the list
  $x: list.nth($coordinates, 1);
  $y: list.nth($coordinates, -1); // -1 gets the last item (y)

  //  Return the combined x and y values with units and separator
  @return #{$x}#{$unit}#{$separator}#{$y}#{$unit};
}

Sure, nothing’s showing up in the preview yet, but the real magic starts now.

Now we move on to writing mixins. I’ll explain the approach in detail for the first mixin, and the rest will be described through comments.

The first mixin dynamically applies grid-column and grid-row placement rules to child elements based on values stored in a map. Each entry in the map corresponds to an element index (1 through 8) and contains a list of four values: [start-col, end-col, start-row, end-row].

@mixin tanagram-grid-positioning($nth-child-grid) {
  // Loop through numbers 1 to 8, corresponding to the tanam pieces
  @for $i from 1 through 8 {

    // Check if the map contains a key for the current piece (1-8)
    @if map.has-key($nth-child-grid, $i) {

      // Get the grid values for this piece: [start-column, end-column, start-row, end-row]
      $values: map.get($nth-child-grid, $i);

      // Target the nth child (piece) and set its grid positions
      &:nth-child(#{$i}) {
        // Set grid-column: start and end values based on the first two items in the list
        grid-column: #{list.nth($values, 1)} / #{list.nth($values, 2)};

        // Set grid-row: start and end values based on the last two items in the list
        grid-row: #{list.nth($values, 3)} / #{list.nth($values, 4)};
      }
    }
  }
}

We can expect the following CSS to be generated:

.tanagram-box:nth-child(1) {
  grid-column: 2 / 3;
  grid-row: 1 / 2;
}

.tanagram-box:nth-child(2) {
  grid-column: 3 / 4;
  grid-row: 1 / 2;
}

In this mixin, my goal was actually to create all the shapes (tans). I am using clip-path. There were ideas to use fancy SVG images, but this test project is more about testing the logic rather than focusing on beautiful design. For this reason, the simplest solution was to cut the elements according to dimensions while they are still in the square (the initial position of all the tans).

So, in this case, through a static calculation, the $tansShapes map was updated with the clip-path property:

clip-path: (0 0, 50 50, 0 100);

This contains the clip points for all the tans. In essence, this mixin shapes and colors each tan accordingly.

@mixin set-tan-clip-path($tanName, $values) {
  //  Initialize an empty list to hold the final clip-path points
  $clip-path-points: ();

  // Extract the 'clip-path' data from the map, which contains coordinate pairs
  $clip-path-key: map.get($values, clip-path);

  // Get the number of coordinate pairs to loop through
  $count: list.length($clip-path-key);

  //  Loop through each coordinate point
  @for $i from 1 through $count {
    //  Convert each pair of numbers into a formatted coordinate string with units
    $current-point: get-coordinates($clip-path-key, $i, " ", "%");

    //  Add the formatted coordinate to the list, separating each point with a comma
    $clip-path-points: list.append($clip-path-points, #{$current-point}, comma);
  }

  //  Style for the preview element (lab version), using the configured background color
  #tan#{$tanName}lab {
    background: map.get($values, color);
    clip-path: polygon(#{$clip-path-points}); // Apply the full list of clip-path points
  }

  //  Apply the same clip-path to the actual tan element
  .#{$tanName} {
    clip-path: polygon(#{$clip-path-points});
  }
}

and output in CSS should be:

.blueTriangle {
  clip-path: polygon(0% 0%, 50% 50%, 0% 100%);
}
/* other tans */

Start logic

Alright, now Iโ€™d like to clarify what should happen first when the game loads.

First, with a click on the Start button, all the tans โ€œgo to their positions.โ€ In reality, we assign them a transform: translate() with specific coordinates and a rotation.

.start:checked ~ .shadow #tanblueTrianglelab {
  transform-origin: 4.17vmin 12.5vmin;
  transform: translate(-6vmin,-37vmin) rotate(360deg);
  cursor: pointer;
}

So, we still maintain this pattern. We use transform and simply change the positions or angles (in the maps) of both the tans and their shadows on the task board.

When any tan is clicked, the rotation button appears. By clicking on it, the tan should rotate around its center, and this continues with each subsequent click. There are actually eight radio buttons, and with each click, one disappears and the next one appears. When we reach the last one, clicking it makes it disappear and the first one reappears. This way, we get the impression of clicking the same button (they are, of course, styled the same) and being able to click (rotate the tan) infinitely. This is exactly what the following mixin enables.

@mixin set-tan-rotation-states($tanName, $values, $angles, $color) {
  // This mixin dynamically applies rotation UI styles based on a tan's configuration.
  // It controls the positioning and appearance of rotation buttons and visual feedback when a rotation state is active.
  @each $angle in $angles{
    & ~ #rot#{$angle}{ transform: translate(get-coordinates($values,rot-btn-position,',',vmin )); background: $color;}
    & ~ #rotation-#{$angle}:checked{
      @each $key in map.keys($tansShapes){
        & ~ #tan#{$key}labRes{ visibility: visible; background:rgba(0,0,0,0.4); }
        & ~ #tan#{$key}lab{ opacity:.3; }
        & ~ #rotReset{ visibility: visible; }
      } 
    }
  }
}

And the generated CSS should be:

#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelab {
  transform: translate(-6vmin,-37vmin) rotate(45deg);
}

#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelabRes {
  visibility: hidden;
}

OK, the following mixins use the set-clip-path and set-rotation mixins. They contain all the information about the tans and their behavior in relation to which tan is clicked and which rotation is selected, as well as their positions (as defined in the second mixin).

@mixin generate-tan-shapes-and-interactions($tansShapes) {
// Applies styling logic and UI interactions for each individual tan shape from the $tansShapes map.
  @each $tanName, $values in $tansShapes{
    $color: color.scale(map.get($values, color), $lightness: 10%); 
    $angles: (45, 90, 135, 180, 225, 270, 315, 360); 
    @include set-tan-clip-path($tanName, $values);

    ##{$tanName}-tan:checked{
      & ~ #tan#{$tanName}Res{ visibility:hidden; }
      & ~ #tan#{$tanName}lab{opacity: 1 !important;background: #{$color};cursor:auto;}
      @each $key in map.keys($tansShapes){
          & ~ #tan#{$tanName}Res:checked ~ #tan#{$key}labRes{visibility: visible;}
      }
      & ~  #rot45{display: flex;visibility: visible;}
      & ~ #rotReset{ transform: translate(get-coordinates($values, exit-mode-btn-position,',', vmin)); }
      @include set-tan-rotation-states($tanName, $values, $angles, $color);
    }  
  }
}
@mixin set-initial-tan-position($tansShapes) {
// This mixin sets the initial position and transformation for both the interactive (`lab`) and shadow (`labRes`) versions
// of each tan shape, based on coordinates provided in the $tansShapes map.
 @each $tanName, $values in $tansShapes{
    & ~ .shadow #tan#{$tanName}lab{
      transform-origin: get-coordinates($values, transform-origin,' ' ,vmin);
      transform: translate( get-coordinates($values,tan-position,',', vmin)) rotate(360deg) ;
      cursor: pointer;
    }
    & ~ .shadow #tan#{$tanName}labRes{
      visibility:hidden;
      transform: translate(get-coordinates($values,diable-lab-position,',',vmin)); 
    }
  }
}

As mentioned earlier, when a tan is clicked, one of the things that becomes visible is its shadow โ€” a silhouette that appears on the task board.

These shadow positions (coordinates) are currently defined statically. Each shadow has a specific place on the map, and a mixin reads this data and applies it to the shadow using transform: translate().

When the clicked tan is rotated, the number of visible shadows on the task board can change, as well as their angles, which is expected.

Of course, special care was taken with naming conventions. Each shadow element gets a unique ID, made from the name (inherited from its parent tan) and a number that represents its sequence position for the given angle.

Pretty cool, right? That way, we avoid complicated naming patterns entirely!

@mixin render-possible-tan-positions( $name, $angle, $possiblePositions, $visibility, $color, $id, $transformOrigin ) {
    // This mixin generates styles for possible positions of a tan shape based on its name, rotation angle, and configuration map.
    // It handles both squares and polygons, normalizing their rotation angles accordingly and applying transform styles if positions exist.}
  @if $name == 'square' {
    $angle: normalize-angle($angle); // Normalizujemo ugao ako je u pitanju square
  } @else if $name == 'polygon'{
    $angle: normalize-polygon-angle($angle);
  }
  @if map.has-key($possiblePositions, $angle) {
    $values: map.get($possiblePositions, $angle);

    @if $values != none {
      $count: list.length($values);

      @for $i from 1 through $count {
        $position: get-coordinates($values, $i, ',', vmin);
        & ~ #tan#{$name}lab-#{$i}-#{$angle} { 
          @if $visibility == visible {
            visibility: visible;
            background-color: $color;
            opacity: .2;
            z-index: 2;
            transform-origin: #{$transformOrigin};
            transform: translate(#{$position}) rotate(#{$angle}deg);
          } @else if $visibility == hidden { visibility: hidden; }
          &:hover{ opacity: 0.5; cursor: pointer; }
        }
      }
    }
  }
}

The generated CSS:

#blueTriangle-tan:checked ~ #tanblueTrianglelab-1-360 {
  visibility: visible;
  background-color: #53a0e0;
  opacity: 0.2;
  z-index: 2;
  transform-origin: 4.17vmin 12.5vmin;
  transform: translate(4.7vmin,13.5vmin) rotate(360deg);
}

This next mixin is tied to the previous one and manages when and how the tan shadows appear while their parent tan is being rotated using the button. It listens for the current rotation angle and checks whether there are any shadow positions defined for that specific angle. If there are, it displays them; if not โ€” no shadows!

@mixin render-possible-positions-by-rotation {
   // This mixin applies rotation to each tan shape. It loops through each tan, calculates its possible positions for each angle, and handles visibility and transformation.
   // It ensures that rotation is applied correctly, including handling the transitions between various tan positions and visibility states.
 @each $tanName, $values in $tansShapes{
    $possiblePositions: map.get($values, poss-positions);
    $possibleTansColor: map.get($values, color);
    $validPosition: get-coordinates($values, correct-position,',' ,vmin);
    $transformOrigin: get-coordinates($values,transform-origin,' ' ,vmin); 
    $rotResPosition: get-coordinates($values,exit-mode-btn-position ,',' ,vmin );
    $angle: 0;
    @for $i from 1 through 8{
      $angle: $i * 45;
      $nextAngle: if($angle + 45 > 360, 45, $angle + 45);
      @include render-position-feedback-on-task($tanName,$angle, $possiblePositions,$possibleTansColor, #{$tanName}-tan, $validPosition,$transformOrigin, $rotResPosition);   
        ##{$tanName}-tan{
        @include render-possible-tan-positions($tanName,$angle, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin)
      }
        ##{$tanName}-tan:checked{
          @include render-possible-tan-positions($tanName,360, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin);
          & ~ #rotation-#{$angle}:checked {
            @include render-possible-tan-positions($tanName,360, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin);
            & ~ #tan#{$tanName}lab{transform:translate( get-coordinates($values,tan-position,',', vmin))  rotate(#{$angle}deg) ;}
            & ~ #tan#{$tanName}labRes{ visibility: hidden; }
            & ~ #rot#{$angle}{ visibility: hidden; }
            & ~ #rot#{$nextAngle}{ visibility: visible } 
            @include render-possible-tan-positions($tanName,$angle, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin);
        }
      }
    }
  }
}

When a tan’s shadow is clicked, the corresponding tan should move to that shadowโ€™s position. The next mixin then checks whether this new position is the correct one for solving the puzzle. If it is correct, the tan gets a brief blinking effect and becomes unclickable, signaling itโ€™s been placed correctly. If itโ€™s not correct, the tan simply stays at the shadowโ€™s location. There’s no effect and it remains draggable/clickable.

Of course, there’s a list of all the correct positions for each tan. Since some tans share the same size โ€” and some can even combine to form larger, existing shapes โ€” we have multiple valid combinations. For this Camel task, all of them were taken into account. A dedicated map with these combinations was created, along with a mixin that reads and applies them.

At the end of the game, when all tans are placed in their correct positions, we trigger a “merging” effect โ€” and the silhouette of the camel turns yellow. At that point, the only remaining action is to click the Restart button.

Well, that was long, but that’s what you get when you pick the fun (albeit hard and lengthy) path. All as an ode to CSS-only magic!


Breaking Boundaries: Building a Tangram Puzzle With (S)CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Chris Coyier
Mon, 09 Jun 2025 16:37:09 +0000


Somebody with good taste couldโ€™ve made my website, but then it wouldnโ€™t be mine.

My website is ugly because I made it โ€” Taylor Troesh

I love weird design ideas. Probably because so much of what we need to do as web designers is, appropriately, somewhat serious. We want things to be simple, clear, professional, so that people understand them and in many cases pay for them. So when the constraints relax, so can we. It’s unlikely that Taylor’s homepage would “perform well” in any sort of UX testing, but who cares? It’s not impossible to use, it’s just unusual. And crucially, it’s fun and memorable, which is likely a leg up on the last “dashboard” you saw.

It’s cool of Blackle Moriย to have documented The Lost CSS Tricks ofย Cohost.org, a social network ultimately too cool for this world. I sort of suspect a lot of this trickery is available in advanced email clients too, where you definitely don’t have JavaScript, but do have access to more-modern-than-you’d-think HTML and CSS.

And high on the tranfixingly-weird scale is Amit Sheen’s CSS Spotlight Effect. Don’t write it off as a black page with a transparent circle moving with the mouse. I mean, it kinda is, but the filtering and scaling affects that come along for the ride are extremely cool. I actually just got to hang with Amit a bit at CSS Day in Amsterdam this past week. His talk was about building logic gates in CSS was pretty wild, and the whole thing end with him just showing off random amazing Pens of his on stage.

Sometimes design can feel impressive because of the extreme constraints of where you’re seeing it. I’m at an airport lounge right now where I’ve seen an exhibit of sculptures carved into the lead tips of pencils. It’s that same kind of feeling I get when I see art happen in the terminal, a place usually not regarded for it’s beauty. Like seeing a daisy grow from the cracks of a busted up sidewalk.

I like serious design as well. Certainly there is more money in it. I’m allowed to like them both, just like I enjoy both fine dining and fast food. I’ll just hit you with some quicker links though as I bet you’re tired of my going on.

  • Chris Nager weighs in on Design Engineering from his experience with that title at Carta. “The most important skill design engineers possess is the ability to communicate with both designers and frontend engineers. Theyโ€™re able to give feedback to both sides, and can act as translators between the two worlds through prototypes.” Emphasis mine, naturally.
  • Lea Verou looks critically at a design change at GitHub in Minimalist Affordances: Making the right tradeoffs. Not only was it interesting, it showcases the power of blogging and making coherent points: GitHub noticed, talked with her, and improved the design.
  • Grant Slatton on How to write a good design document. “Think of a design document like a proof in mathematics. The goal of a proof is to convince the reader that the theorem is true. The goal of a design document is to convince the reader the design is optimal given the situation.”
by: Preethi
Mon, 09 Jun 2025 12:58:37 +0000


The HTML popover attribute transforms elements into top-layer elements that can be opened and closed with a button or JavaScript. Most popovers can be light-dismissed, closing when the user clicks or taps outside the popup. Currently, HTML popover lacks built-in auto-close functionality, but itโ€™s easy to add. Auto closing popups are useful for user interfaces like banner notifications โ€” the new-message alerts in phones, for instance.

A picture demo, is worth a thousand words, right? Click on the โ€œAdd to my bookmarksโ€ button in the following example. It triggers a notification that dismisses itself after a set amount of time.

Letโ€™s start with the popover

The HTML popover attribute is remarkably trivial to use. Slap it on a div, specify the type of popover you need, and youโ€™re done.

<div popover="manual" id="pop">Bookmarked!</div>

A manual popover simply means it cannot be light-dismissed by clicking outside the element. As a result, we have to hide, show, or toggle the popoverโ€™s visibility ourselves explicitly with either buttons or JavaScript. Letโ€™s use a semantic HTML button.

<button popovertarget="pop" popovertargetaction="show">
  Add to my bookmarks
</button>
<div popover="manual" id="pop">Bookmarked!</div>

The popovertarget and popovertargetaction attributes are the final two ingredients, where popovertarget links the button to the popover element and popovertargetaction ensures that the popover is show-n when the button is clicked.

Hiding the popover with a CSS transition

OK, so the challenge is that we have a popover that is shown when a certain button is clicked, but it cannot be dismissed. The button is only wired up to show the popover, but it does not hide or toggle the popover (since we are not explicitly declaring it). We want the popover to show when the button is clicked, then dismiss itself after a certain amount of time.

The HTML popover canโ€™t be closed with CSS, but it can be hidden from the page. Adding animation to that creates a visual effect. In our example, we will hide the popover by eliminating its CSS height property. Youโ€™ll learn in a moment why weโ€™re using height, and that there are other ways you can go about it.

We can indeed select the popover attribute using an attribute selector:

[popover] {
  height: 0;
  transition: height cubic-bezier(0.6, -0.28, 0.735, 0.045) .3s .6s;

  @starting-style { 
    height: 1lh;
  }
}

When the popover is triggered by the button, its height value is the one declared in the @starting-style ruleset (1lh). After the transition-delay (which is .6s in the example), the height goes from 1lh to 0 in .3s, effectively hiding the popover.

Once again, this is only hiding the popover, not closing it properly. Thatโ€™s the next challenge and weโ€™ll need JavaScript for that level of interaction.

Closing the popover with JavaScript

We can start by setting a variable that selects the popover:

const POPOVER = document.querySelector('[popover]');

Next, we can establish a ResizeObserver that monitors the popoverโ€™s size:

const POPOVER = document.querySelector('[popover]');
const OBSERVER = 
  new ResizeObserver((entries) => {
    if(entries[0].contentBoxSize[0].blockSize == 0) 
      OBSERVER.unobserve((POPOVER.hidePopover(), POPOVER));
  });

And we can fire that off starting when the button to show the popover is clicked:

const POPOVER = document.querySelector('[popover]');
const OBSERVER = 
  new ResizeObserver((entries) => {
    if(entries[0].contentBoxSize[0].blockSize == 0) 
      OBSERVER.unobserve((POPOVER.hidePopover(), POPOVER));
  });
document.querySelector('button').onclick = () => OBSERVER.observe(POPOVER);

The observer will know when the popoverโ€™s CSS height reaches zero at the end of the transition, and, at that point, the popover is closed with hidePopover(). From there, the observer is stopped with unobserve().

In our example, height and ResizeObserver are used to auto-close the notification. You can try any other CSS property and JavaScript observer combination that might work with your preference. Learning about ResizeObserver and MutationObserver can help you find some options.

Setting an HTML fallback

When JavaScript is disabled in the browser, if the popover type is set to any of the light-dismissible types, it acts as a fallback. Keep the popover visible by overriding the style rules that hide it. The user can dismiss it by clicking or tapping anywhere outside the element.

If the popover needs to be light-dismissible only when JavaScript is disabled, then include that popover inside a <noscript> element before the manual popover. Itโ€™s the same process as before, where you override CSS styles as needed.

<noscript>
  <div popover="auto" id="pop">Bookmarked!</div>
</noscript>

<div popover="manual" id="pop">Bookmarked!</div>

<!-- goes where <head> element's descendants go -->
<noscript>
  <style>
    [popover] {
      transition: none;
      height: 1lh;
    }
  </style>
</noscript>

When to use this method?

Another way to implement all of this would be to use setTimeout() to create a delay before closing the popover in JavaScript when the button is clicked, then adding a class to the popover element to trigger the transition effect. That way, no observer is needed.

With the method covered in this post, the delay can be set and triggered in CSS itself, thanks to @starting-style and transition-delay โ€” no extra class required! If you prefer to implement the delay through CSS itself, then this method works best. The JavaScript will catch up to the change CSS makes at the time CSS defines, not the other way around.


Creating an Auto-Closing Notification With an HTML Popover originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Temani Afif
Fri, 06 Jun 2025 13:52:42 +0000


If youโ€™re following along, this is the third post in a series about the new CSS shape() function. Weโ€™ve learned how to draw lines and arcs and, in this third part, I will introduce the curve command โ€” the missing command you need to know to have full control over the shape() function. In reality, there are more commands, but you will rarely need them and you can easily learn about them later by checking the documentation.

Better CSS Shapes Using shape()

  1. Lines and Arcs
  2. More on Arcs
  3. Curves (you are here!)

The curve command

This command adds a Bรฉzier curve between two points by specifying control points. We can either have one control point and create a Quadratic curve or two control points and create a Cubic curve.

Bรฉzier, Quadratic, Cubic, control points? What?!

For many of you, that definition is simply unclear, or even useless! You can spend a few minutes reading about Bรฉzier curves but is it really worth it? Probably not, unless your job is to create shapes all the day and you have a solid background in geometry.

We already have cubic-bezier() as an easing function for animations but, honestly, who really understands how it works? We either rely on a generator to get the code or we read a โ€œboringโ€ explanation that we forget in two minutes. (I have one right here by the way!)

Donโ€™t worry, this article will not be boring as I will mostly focus on practical examples and more precisely the use case of rounding the corners of irregular shapes. Here is a figure to illustrate a few examples of Bรฉzier curves.

Comparing two curved lines, one with one control point and one with two control points.

The blue dots are the starting and ending points (letโ€™s call them A and B) and the black dots are the control points. And notice how the curve is tangent to the dashed lines illustrated in red.

In this article, I will consider only one control point. The syntax will follow this pattern:

clip-path: shape(
  from Xa Ya, 
  curve to Xb Yb with Xc Yc
);

arc command vs. curve command

We already saw in Part 1 and Part 2 that the arc command is useful establishing rounded edges and corners, but it will not cover all the cases. Thatโ€™s why you will need the curve command. The tricky part is to know when to use each one and the answer is โ€œit depends.โ€ There is no generic rule but my advice is to first see if itโ€™s possible (and easy) using arc. If not, then you have to use curve.

For some shapes, we can have the same result using both commands and this is a good starting point for us to understand the curve command and compare it with arc.

Take the following example:

This is the code for the first shape:

.shape {
  clip-path: shape(from 0 0,
    arc to 100% 100% of 100% cw,
    line to 0 100%)
}

And for the second one, we have this:

.shape {
  clip-path: shape(from 0 0,
    curve to 100% 100% with 100% 0,
    line to 0 100%)
}

The arc command needs a radius (100% in this case), but the curve command needs a control point (which is 100% 0 in this example).

Two rounded shapes that appear to have similar curves, one using an arc and another using a curve.

Now, if you look closely, you will notice that both results arenโ€™t exactly the same. The first shape using the arc command is creating a quarter of a circle, whereas the shape using the curve command is slightly different. If you place both of them above each other, you can clearly see the difference.

This is interesting because it means we can round some corners using either an arc or a curve, but with slightly different results. Which one is better, you ask? I would say it depends on your visual preference and the shape you are creating.

In Part 1, we created rounded tabs using the arc command, but we can also create them with curve.

Can you spot the difference? Itโ€™s barely visible but itโ€™s there.

Notice how I am using the by directive the same way I am doing with arc, but this time we have the control point, which is also relative. This part can be confusing, so pay close attention to this next bit.

Consider the following:

shape(from Xa Ya, curve by Xb Yb with Xc Yc)

It means that both (Xb,Yb) and (Xc,Yc) are relative coordinates calculated from the coordinate of the starting point. The equivalent of the above using a to directive is this:

shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xc) (Yb + Yc))

We can change the reference of the control point by adding a from directive. We can either use start (the default value), end, or origin.

shape(from Xa Ya, curve by Xb Yb with Xc Yc from end)

The above means that the control point will now consider the ending point instead of the starting point. The result is similar to:

shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xb + Xc) (Ya + Yb + Yc))

If you use origin, the reference will be the origin, hence the coordinate of the control point becomes absolute instead of relative.

The from directive may add some complexity to the code and the calculation, so donโ€™t bother yourself with it. Simply know it exists in case you face it, but keep using the default value.

I think itโ€™s time for your first homework! Similar to the rounded tab exercise, try to create the inverted radius shape we covered in the Part 1 using curve instead of arc. Here are both versions for you to reference, but try to do it without peeking first, if you can.

Letโ€™s draw more shapes!

Now that we have a good overview of the curve command, letโ€™s consider more complex shapes where arc won’t help us round the corners and the only solution is to draw curves instead. Considering that each shape is unique, so I will focus on the technique rather than the code itself.

Slanted edge

Letโ€™s start with a rectangular shape with a slanted edge.

A slanted rectangle shape in two stages, first with sharp edges, then with curved edges.

Getting the shape on the left is quite simple, but the shape on the right is a bit tricky. We can round two corners with a simple border-radius, but for the slanted edge, we will use shape() and two curve commands.

The first step is to write the code of the shape without rounded corners (the left one) which is pretty straightforward since weโ€™re only working with the line command:

.shape {
  --s: 90px;  /* slant size */

  clip-path: 
    shape(from 0 0,
    line to calc(100% - var(--s)) 0,
    line to 100% 100%,
    line to 0 100%
    );
}

Then we take each corner and try to round it by modifying the code. Here is a figure to illustrate the technique I am going to use for each corner.

Diagrammiong a rounded rectangular shape in three stages, first with sharp edges, then with points indicating where the curve control points are, then the completed shape.

We define a distance, R, that controls the radius. From each side of the corner point, I move by that distance to create two new points, which are illustrated above in red. Then, I draw my curve using the new points as starting and ending points. The corner point will be the control point.

The code becomes:

.shape {
  --s: 90px;  /* slant size */

  clip-path: 
    shape(from 0 0,
    Line  to Xa Ya,
    curve to Xb Yb with calc(100% - var(--s)) 0,
    line to 100% 100%,
    line to 0 100%
    );
}

Notice how the curve is using the coordinates of the corner point in the with directive, and we have two new points, A and B.

Until now, the technique is not that complex. For each corner point, you replace the line command with line + curve commands where the curve command reuses the old point in its with directive.

If we apply the same logic to the other corner, we get the following:

.shape {
  --s: 90px;  /* slant size */

  clip-path: 
    shape(from 0 0,
    line  to Xa Ya, 
    curve to Xb Yb with calc(100% - var(--s)) 0,
    line  to Xc Yc,
    curve to Xd Yd with 100% 100%,
    line to 0 100%
    );
}

Now we need to calculate the coordinates of the new points. And here comes the tricky part because itโ€™s not always simple and it may require some complex calculation. Even if I detail this case, the logic wonโ€™t be the same for the other shapes weโ€™re making, so I will skip the math part and give you the final code:

.box {
  --h: 200px; /* element height */
  --s: 90px;  /* slant size */
  --r: 20px;  /* radius */
  
  height: var(--h);
  border-radius: var(--r) 0 0 var(--r);
  --_a: atan2(var(--s), var(--h));
  clip-path: 
    shape(from 0 0,
    line  to calc(100% - var(--s) - var(--r)) 0,
    curve by calc(var(--r) * (1 + sin(var(--_a)))) 
              calc(var(--r) * cos(var(--_a)))
    with var(--r) 0,
    line  to calc(100% - var(--r) * sin(var(--_a))) 
              calc(100% - var(--r) * cos(var(--_a))),
    curve to calc(100% - var(--r)) 100%  with 100% 100%,
    line to 0 100%
    );
}

I know the code looks a bit scary, but the good news is that the code is also really easy to control using CSS variables. So, even if the math is not easy to grasp, you donโ€™t have to deal with it. It should be noted that I need to know the height to be able to calculate the coordinates which means the solution isnโ€™t perfect because the height is a fixed value.

Arrow-shaped box

Hereโ€™s a similar shape, but this time we have three corners to round using the curve command.

The final code is still complex but I followed the same steps. I started with this:

.shape {
  --s: 90px; 

  clip-path: 
    shape(from 0 0,
    /* corner #1 */
    line to calc(100% - var(--s)) 0,
    /* corner #2 */
    line to 100% 50%,
    /* corner #3 */
    line to calc(100% - var(--s)) 100%,

    line to 0 100%
    );
}

Then, I modified it into this:

.shape {
  --s: 90px; 

  clip-path: 
    shape(from 0 0,
    /* corner #1 */
    line  to Xa Ya
    curve to Xb Yb with calc(100% - var(--s)) 0,
    /* corner #2 */
    line  to Xa Ya
    curve to Xb Yb with 100% 50%,
    /* corner #3 */
    line  to Xa Yb
    curve to Xb Yb with calc(100% - var(--s)) 100%,

    line to 0 100%
    );
}

Lastly, I use a pen and paper to do all the calculations.

You might think this technique is useless if you are not good with math and geometry, right? Not really, because you can still grab the code and use it easily since itโ€™s optimized using CSS variables. Plus, you arenโ€™t obligated to be super accurate and precise. You can rely on the above technique and use trial and error to approximate the coordinates. It will probably take you less time than doing all the math.

Rounded polygons

I know you are waiting for this, right? Thanks to the new shape() and the curve command, we can now have rounded polygon shapes!

Three rounded polygon shapes, first a pentagon, second a triangle, and third an octagon.

Here is my implementation using Sass where you can control the radius, number of sides and the rotation of the shape:

If we omit the complex geometry part, the loop is quite simple as it relies on the same technique with a line + curve per corner.

$n: 9; /* number of sides*/
$r: .2; /* control the radius [0 1] */
$a: 15deg; /* control the rotation */

.poly {
  aspect-ratio: 1;
  $m: ();
  @for $i from 0 through ($n - 1) {
    $m: append($m, line  to Xai Yai, comma);
    $m: append($m, curve to Xbi Ybi with Xci Yci, comma);
  } 
  clip-path: shape(#{$m});
}

Here is another implementation where I define the variables in CSS instead of Sass:

Having the variables in CSS is pretty handy especially if you want to have some animations. Here is an example of a cool hover effect applied to hexagon shapes:

I have also updated my online generator to add the radius parameter. If you are not familiar with Sass, you can easily copy the CSS code from there. You will also find the border-only and cut-out versions!

A rounded shape with six sides next to a cutout of a rounded shape with six edges.

Conclusion

Are we done with the curve command? Probably not, but we have a good overview of its potential and all the complex shapes we can build with it. As for the code, I know that we have reached a level that is not easy for everyone. I could have extended the explanation by explicitly breaking down the math, but then this article would be overly complex and make it seem like using shape() is harder than it is.

This said, most of the shapes I code are available within my online collection that I constantly update and optimize so you can easily grab the code of any shape!

If you want a good follow-up to this article, I wrote an article for Frontend Masters where you can create blob shapes using the curve command.

Three blob shapes in a single row, each colored with a gradient that goes left to right from dark orange to light orange.

Better CSS Shapes Using shape()

  1. Lines and Arcs
  2. More on Arcs
  3. Curves (you are here!)

Better CSS Shapes Using shape() โ€” Part 3: Curves originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Daniel Schwarz
Thu, 05 Jun 2025 13:45:56 +0000


In many countries, web accessibility is a human right and the law, and there can be heavy fines for non-compliance. Naturally, this means that text and icons and such must have optimal color contrast in accordance with the benchmarks set by the Web Content Accessibility Guidelines (WCAG). Now, there are quite a few color contrast checkers out there (Figma even has one built-in now), but the upcoming contrast-color() function doesnโ€™t check color contrast, it outright resolves to either black or white (whichever one contrasts the most with your chosen color).

Right off the bat, you should know that weโ€™ve sorta looked at this feature before. Back then, however, it was called color-contrast() instead of contrast-color() and had a much more convoluted way of going about things. It was only released in Safari Technology Preview 122 back in 2021, and thatโ€™s still the case at the time Iโ€™m writing this (now at version 220).

Youโ€™d use it like this:

button {
  --background-color: darkblue;
  background-color: var(--background-color);
  color: contrast-color(var(--background-color));
}

Here, contrast-color() has determined that white contrasts with darkblue better than black does, which is why contrast-color() resolves to white. Pretty simple, really, but there are a few shortcomings, which includes a lack of browser support (again, itโ€™s only in Safari Technology Preview at the moment).

We can use contrast-color() conditionally, though:

@supports (color: contrast-color(red)) {
  /* contrast-color() supported */
}

@supports not (color: contrast-color(red)) {
  /* contrast-color() not supported */
}

The shortcomings of contrast-color()

First, let me just say that improvements are already being considered, so here Iโ€™ll explain the shortcomings as well as any improvements that Iโ€™ve heard about.

Undoubtedly, the number one shortcoming is that contrast-color() only resolves to either black or white. If you donโ€™t want black or white, wellโ€ฆ that sucks. However, the draft spec itself alludes to more control over the resolved color in the future.

But thereโ€™s one other thing thatโ€™s surprisingly easy to overlook. What happens when neither black nor white is actually accessible against the chosen color? Thatโ€™s right, itโ€™s possible for contrast-color() to justโ€ฆ not provide a contrasting color. Ideally, I think weโ€™d want contrast-color() to resolve to the closest accessible variant of a preferred color. Until then, contrast-color() isnโ€™t really usable.

Another shortcoming of contrast-color() is that it only accepts arguments of the <color> data type, so itโ€™s just not going to work with images or anything like that. I did, however, manage to make it โ€œworkโ€ with a gradient (basically, two instances of contrast-color() for two color stops/one linear gradient):

<button>
  <span>A button</span>
</button>
button {
  background: linear-gradient(to right, red, blue);

  span {
    background: linear-gradient(to right, contrast-color(red), contrast-color(blue));
    color: transparent;
    background-clip: text;
  }
}

The reason this looks so horrid is that, as mentioned before, contrast-color() only resolves to black or white, so in the middle of the gradient we essentially have 50% grey on purple. This problem would also get solved by contrast-color() resolving to a wider spectrum of colors.

But what about the font size? As you might know already, the criteria for color contrast depends on the font size, so how does that work? Well, at the moment it doesnโ€™t, but I think itโ€™s safe to assume that itโ€™ll eventually take the font-size into account when determining the resolved color. Which brings us to APCA.

APCA (Accessible Perceptual Contrast Algorithm) is a new algorithm for measuring color contrast reliably. Andrew Somers, creator of APCA, conducted studies (alongside many other independent studies) and learned that 23% of WCAG 2 “Fails” are actually accessible. In addition, an insane 47% of “Passes” are inaccessible.

Not only should APCA do a better job, but the APCA Readability Criterion (ARC) is far more nuanced, taking into account a much wider spectrum of font sizes and weights (hooray for me, as Iโ€™m very partial to 600 as a standard font weight). While the criterion is expectedly complex and unnecessarily confusing, the APCA Contrast Calculator does a decent-enough job of explaining how it all works visually, for now.

contrast-color() doesnโ€™t use APCA, but the draft spec does allude to offering more algorithms in the future. This wording is odd as it suggests that weโ€™ll be able to choose between the APCA and WCAG algorithms. Then again, we have to remember that the laws of some countries will require WCAG 2 compliance while others require WCAG 3 compliance (when it becomes a standard).

Thatโ€™s right, weโ€™re a long way off of APCA becoming a part of WCAG 3, let alone contrast-color(). In fact, it might not even be a part of it initially (or at all), and there are many more hurdles after that, but hopefully this sheds some light on the whole thing. For now, contrast-color() is using WCAG 2 only.

Using contrast-color()

Hereโ€™s a simple example (the same one from earlier) of a darkblue-colored button with accessibly-colored text chosen by contrast-color(). Iโ€™ve put this darkblue color into a CSS variable so that we can define it once but reference it as many times as is necessary (which is just twice for now).

button {
  --background-color: darkblue;
  background-color: var(--background-color);
  /* Resolves to white */
  color: contrast-color(var(--background-color));
}

And the same thing but with lightblue:

button {
  --background-color: lightblue;
  background-color: var(--background-color);
  /* Resolves to black */
  color: contrast-color(var(--background-color));
}

First of all, we can absolutely switch this up and use contrast-color() on the background-color property instead (or in-place of any <color>, in fact, like on a border):

button {
  --color: darkblue;
  color: var(--color);
  /* Resolves to white */
  background-color: contrast-color(var(--color));
}

Any valid <color> will work (named, HEX, RGB, HSL, HWB, etc.):

button {
  /* HSL this time */
  --background-color: hsl(0 0% 0%);
  background-color: var(--background-color);
  /* Resolves to white */
  color: contrast-color(var(--background-color));
}

Need to change the base color on the fly (e.g., on hover)? Easy:

button {
  --background-color: hsl(0 0% 0%);
  background-color: var(--background-color);
  /* Starts off white, becomes black on hover */
  color: contrast-color(var(--background-color));

  &:hover {
    /* 50% lighter */
    --background-color: hsl(0 0% 50%);
  }
}

Similarly, we could use contrast-color() with the light-dark() function to ensure accessible color contrast across light and dark modes:

:root {
  /* Dark mode if checked */
  &:has(input[type="checkbox"]:checked) {
    color-scheme: dark;
  }

  /* Light mode if not checked */
  &:not(:has(input[type="checkbox"]:checked)) {
    color-scheme: light;
  }

  body {
    /* Different background for each mode */
    background: light-dark(hsl(0 0% 50%), hsl(0 0% 0%));
    /* Different contrasted color for each mode */
    color: light-dark(contrast-color(hsl(0 0% 50%)), contrast-color(hsl(0 0% 0%));
  }
}

The interesting thing about APCA is that it accounts for the discrepancies between light mode and dark mode contrast, whereas the current WCAG algorithm often evaluates dark mode contrast inaccurately. This one nuance of many is why we need not only a new color contrast algorithm but also the contrast-color() CSS function to handle all of these nuances (font size, font weight, etc.) for us.

This doesnโ€™t mean that contrast-color() has to ensure accessibility at the expense of our โ€œdesignedโ€ colors, though. Instead, we can use contrast-color() within the prefers-contrast: more media query only:

button {
  --background-color: hsl(270 100% 50%);
  background-color: var(--background-color);
  /* Almost white (WCAG AA: Fail) */
  color: hsl(270 100% 90%);

  @media (prefers-contrast: more) {
    /* Resolves to white (WCAG AA: Pass) */
    color: contrast-color(var(--background-color));
  }
}

Personally, Iโ€™m not keen on prefers-contrast: more as a progressive enhancement. Great color contrast benefits everyone, and besides, we canโ€™t be sure that those who need more contrast are actually set up for it. Perhaps theyโ€™re using a brand new computer, or they just donโ€™t know how to customize accessibility settings.

Closing thoughts

So, contrast-color() obviously isnโ€™t useful in its current form as it only resolves to black or white, which might not be accessible. However, if it were improved to resolve to a wider spectrum of colors, thatโ€™d be awesome. Even better, if it were to upgrade colors to a certain standard (e.g., WCAG AA) if they donโ€™t already meet it, but let them be if they do. Sort of like a failsafe approach? This means that web browsers would have to take the font size, font weight, element, and so on into account.

To throw another option out there, thereโ€™s also the approach that Windows takes for its High Contrast Mode. This mode triggers web browsers to overwrite colors using the forced-colors: active media query, which we can also use to make further customizations. However, this effect is quite extreme (even though we can opt out of it using the forced-colors-adjust CSS property and use our own colors instead) and macOSโ€™s version of the feature doesnโ€™t extend to the web.

I think that forced colors is an incredible idea as long as users can set their contrast preferences when they set up their computer or browser (the browser would be more enforceable), and there are a wider range of contrast options. And then if you, as a designer or developer, donโ€™t like the enforced colors, then you have the option to meet accessibility standards so that they donโ€™t get enforced. In my opinion, this approach is the most user-friendly and the most developer-friendly (assuming that you care about accessibility). For complete flexibility, there could be a CSS property for opting out, or something. Just color contrast by default, but you can keep the colors youโ€™ve chosen as long as theyโ€™re accessible.

What do you think? Is contrast-color() the right approach, or should the user agent bear some or all of the responsibility? Or perhaps youโ€™re happy for color contrast to be considered manually?


Exploring the CSSย contrast-color()ย Functionโ€ฆ a Second Time originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Juan Diego Rodrรญguez
Thu, 05 Jun 2025 11:13:00 +0000


The State of CSS 2025 Survey dropped a few days ago, and besides waiting for the results, it’s exciting to see a lot of the new things shipped to CSS over the past year reflected in the questions. To be specific, the next survey covers the following features:

Again, a lot!

However, I think the most important questions (regarding CSS) are asked at the end of each section. I am talking about the “What are your top CSS pain points related to ______?” questions. These sections are optional, but help user agents and the CSS Working Group know what they should focus on next.

By nature of comments, those respondents with strong opinions are most likely to fill them in, skewing data towards issues that maybe the majority doesn’t have. So, even if you don’t have a hard-set view on a CSS pain point, I encourage you to fill them โ€” even with your mild annoyances.


The State of CSS 2025 Survey is out! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Andy Clarke
Tue, 03 Jun 2025 14:39:04 +0000


Like โ€™em or loath โ€™em, whether youโ€™re showing an alert, a message, or a newsletter signup, dialogue boxes draw attention to a particular piece of content without sending someone to a different page. In the past, dialogues relied on a mix of divisions, ARIA, and JavaScript. But the HTML dialog element has made them more accessible and style-able in countless ways.

So, how can you take dialogue box design beyond the generic look of frameworks and templates? How can you style them to reflect a brandโ€™s visual identity and help to tell its stories? Hereโ€™s how I do it in CSS using ::backdrop, backdrop-filter, and animations.

Homepage design for Mike Worth website, showing a cartoon gorilla in a dark cave in an Indiana Jones style.
Design by Andy Clarke, Stuff & Nonsense. Mike Worthโ€™s website will launch in June 2025, but you can see examples from this article on CodePen.

I mentioned before that Emmy-award-winning game composer Mike Worth hired me to create a highly graphical design. Mike loves โ€™90s animation, and he challenged me to find ways to incorporate its retro style without making a pastiche. However, I also needed to achieve that retro feel while maintaining accessibility, performance, responsiveness, and semantics.

A brief overview of dialog and ::backdrop

Letโ€™s run through a quick refresher.

Note: While I mostly refer to โ€œdialogue boxesโ€ throughout, the HTML element is spelt dialog.

dialog is an HTML element designed for implementing modal and non-modal dialogue boxes in products and website interfaces. It comes with built-in functionality, including closing a box using the keyboard Esc key, focus trapping to keep it inside the box, show and hide methods, and a ::backdrop pseudo-element for styling a boxโ€™s overlay.

The HTML markup is just what you might expect:

<dialog>
  <h2>Keep me informed</h2>
  <!-- ... -->
  <button>Close</button>
</dialog>

This type of dialogue box is hidden by default, but adding the open attribute makes it visible when the page loads:

<dialog open>
  <h2>Keep me informed</h2>
  <!-- ... -->
  <button>Close</button>
</dialog>

I canโ€™t imagine too many applications for non-modals which are open by default, so ordinarily I need a button which opens a dialogue box:

<dialog>
  <!-- ... -->
</dialog>
<button>Keep me informed</button>

Plus a little bit of JavaScript, which opens the modal:

const dialog = document.querySelector("dialog");
const showButton = document.querySelector("dialog + button");
showButton.addEventListener("click", () => {
  dialog.showModal();
});

Closing a dialogue box also requires JavaScript:

const closeButton = document.querySelector("dialog button");
closeButton.addEventListener("click", () => {
  dialog.close();
});

Unless the box contains a form using method="dialog", which allows it to close automatically on submit without JavaScript:

<dialog>
  <form method="dialog">
    <button>Submit</button>
  </form>
</dialog>

The dialog element was developed to be accessible out of the box. It traps focus, supports the Esc key, and behaves like a proper modal. But to help screen readers announce dialogue boxes properly, youโ€™ll want to add an aria-labelledby attribute. This tells assistive technology where to find the dialogue boxโ€™s title so it can be read aloud when the modal opens.

<dialog aria-labelledby="dialog-title">
  <h2 id="dialog-title">Keep me informed</h2>
  <!-- ... -->
</dialog>

Most tutorials Iโ€™ve seen include very little styling for dialog and ::backdrop, which might explain why so many dialogue boxes have little more than border radii and a box-shadow applied.

Two examples of plain-looking dialogs with white backgrounds and box shadows.
Out-of-the-box dialogue designs

I believe that every element in a design โ€” no matter how small or infrequently seen โ€” is an opportunity to present a brand and tell a story about its products or services. I know there are moments during someoneโ€™s journey through a design where paying special attention to design can make their experience more memorable.

Dialogue boxes are just one of those moments, and Mike Worthโ€™s design offers plenty of opportunities to reflect his brand or connect directly to someoneโ€™s place in Mikeโ€™s story. That might be by styling a newsletter sign-up dialogue to match the scrolls in his news section.

Dialog in the design is an open scroll with script lettering and an email signup form.
Mike Worth concept design, designed by Andy Clarke, Stuff & Nonsense.

Or making the form modal on his error pages look like a comic-book speech balloon.

Example of a dialog in the shape of a shat bubble with an email signup form inside.
Mike Worth concept design, designed by Andy Clarke, Stuff & Nonsense.

dialog in action

Mikeโ€™s drop-down navigation menu looks like an ancient stone tablet.

A menu of links set against a cartoon stone tablet illustration.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.

I wanted to extend this look to his dialogue boxes with a three-dimensional tablet and a jungle leaf-filled backdrop.

A dialog against a cartoon stone tablet illustration with an email signup for inside.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.

This dialog contains a newsletter sign-up form with an email input and a submit button:

<dialog>
  <h2>Keep me informed</h2>
  <form>
    <label for="email" data-visibility="hidden">Email address</label>
    <input type="email" id="email" required>
    <button>Submit</button>
  </form>
  <button>x</button>
</dialog>

I started by applying dimensions to the dialog and adding the SVG stone tablet background image:

dialog {
  width: 420px;
  height: 480px;
  background-color: transparent;
  background-image: url("dialog.svg");
  background-repeat: no-repeat;
  background-size: contain;
}

Then, I added the leafy green background image to the dialogue boxโ€™s generated backdrop using the ::backdrop pseudo element selector:

dialog::backdrop {
  background-image: url("backdrop.svg");
  background-size: cover;
}
Dialog sitting on top of a safari jungle pattern as the backdrop. The dialog is styled with a cartoon stone tablet illustration with an email signup form inside.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.

I needed to make it clear to anyone filling in Mikeโ€™s form that their email address is in a valid format. So I combined :has and :valid CSS pseudo-class selectors to change the color of the submit button from grey to green:

dialog:has(input:valid) button {
  background-color: #7e8943;
  color: #fff;
}

I also wanted this interaction to reflect Mikeโ€™s fun personality. So, I also changed the dialog background image and applied a rubberband animation to the box when someone inputs a valid email address:

dialog:has(input:valid) {
  background-image: url("dialog-valid.svg");
  animation: rubberBand 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}

@keyframes rubberBand {
  from { transform: scale3d(1, 1, 1); }
  30% { transform: scale3d(1.25, 0.75, 1); }
  40% { transform: scale3d(0.75, 1.25, 1); }
  50% { transform: scale3d(1.15, 0.85, 1); }
  65% { transform: scale3d(0.95, 1.05, 1); }
  75% { transform: scale3d(1.05, 0.95, 1); }
  to { transform: scale3d(1, 1, 1); }
}

Tip: Daniel Edenโ€™s Animate.css library is a fabulous source of โ€œJust-add-water CSS animationsโ€ like the rubberband I used for this dialogue box.

Changing how an element looks when it contains a valid input is a fabulous way to add interactions that are, at the same time, fun and valuable for the user.

Dialog sitting on top of a safari jungle pattern as the backdrop. The dialog is styled with a cartoon stone tablet illustration with an email signup form inside.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.

That combination of :has and :valid selectors can even be extended to the ::backdrop pseudo-class, to change the backdropโ€™s background image:

dialog:has(input:valid)::backdrop {
  background-image: url("backdrop-valid.svg");
}

Try it for yourself:

Conclusion

We often think of dialogue boxes as functional elements, as necessary interruptions, but nothing more. But when you treat them as opportunities for expression, even the smallest parts of a design can help shape a product or websiteโ€™s personality.

The HTML dialog element, with its built-in behaviours and styling potential, opens up opportunities for branding and creative storytelling. Thereโ€™s no reason a dialogue box canโ€™t be as distinctive as the rest of your design.


Andy Clarke

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

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

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


Getting Creative With HTML Dialog originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Chris Coyier
Mon, 02 Jun 2025 17:01:09 +0000


Let’s do typography stuff!

  • Video: “A live demo by me of early font editors on a real Macintosh Plus” by Mark Simonson
  • Font: “Is this font easy for you to read? Goodโ€”thatโ€™s the idea.” Hyperlegible is the name of the font, designed for people with low vision.
  • Technique: “Fluid typography means thinking in terms of type scales and flexible spacing across your defined design space.” Richardย Rutter goes retrofitting a new type sizing technique into an old layout. Miriam is also thinking about all this. Richard also has a good one on avoiding faux bold, which has afflicted me many times. My hot take is that browsers shouldn’t do faux nuthin.
  • Font: “To help young readers of all skill levels, weโ€™re introducing Kermit, a child-friendly typeface created by the type design studioย Underware.” It’s fairly pricey but I can imagine it being perfect for some projects. Like Comic Sans but cooler. P.S. you should really try Comic Code on CodePen, it’s awesome.
  • Fonts: “UNCUT.wtf is a free typeface catalogue, focusing on somewhat contemporary type.” A lot of them are super similar which makes me wonder if many of them come from students taking a design class or something.
  • Journey: “One day, I saw what felt like Gorton on a ferry traversing the waters Bay Area. A few weeks later, I spotted it on a sign in a national park. Then on an intercom. On a street lighting access cover. In an elevator. At my dentistโ€™s office. In an alley.” Marcin Wichary on Manhattan’s hardest working typeface.
  • Performance: “… file sizes of web fonts? I personally don’t have a gut feeling how much is too much and how much is to be expected.” Stoyan Stefanov reckons 20k is fair.
  • Behind the Scenes: “This meant that on web we could simply start our font stacks withย Verdana, pick a couple of reasonable fallbacks, and get IKEA branding effectively for free.” IKEA didn’t end up using Verdana, but I wish they did, I kinda love it at small sizes. This is a great look at a major typographic choice at a major brand from Robin Whittleton.
  • Technique: “…we addedย text-wrap: balanceย on WordPress.org, and quickly got community feedback that it led to awkward, unexpected breaks in Japanese and Korean.” Kelly Choyce-Dwan whips out stuff like word-break: auto-phrase; for the win.
by: Temani Afif
Fri, 30 May 2025 13:45:43 +0000


Ready for the second part? We are still exploring the shape() function, and more precisely, the arc command. I hope you took the time to digest the first part because we will jump straight into creating more shapes!

As a reminder, the shape() function is only supported in Chrome 137+ and Safari 18.4+ as I’m writing this in May 2025.

Sector shape

Another classic shape that can also be used in pie-like charts.

A series of three semi-circles.

Itโ€™s already clear that we have one arc. As for the points, we have two points that donโ€™t move and one that moves depending on how much the sector is filled.

Diagram showing the fixed and variable lengths of an arc shape.

The code will look like this:

.sector {
  --v: 35; /* [0 100]*/
  
  aspect-ratio: 1;
  clip-path: shape(from top, arc to X Y of R, line to center);
}

We define a variable that will control the filling of the sector. It has a value between 0 and 100. To draw the shape, we start from the top, create an arc until the point (X, Y), and then we move to the center.

Are we allowed to use keyword values like top and center?

Yes! Unlike the polygon() function, we have keywords for the particular cases such as top, bottom, left, etc. Itโ€™s exactly like background-position that way. I donโ€™t think I need to detail this part as itโ€™s trivial, but itโ€™s good to know because it can make your shape a bit easier to read.

The radius of the arc should be equal to 50%. We are working with a square element and the sector, which is a portion of a circle, need to fill the whole element so the radius is equal to half the width (or height).1

As for the point, itโ€™s placed within that circle, and its position depends on the V value. You donโ€™t want a boring math explanation, right? No need for it, here is the formula of X and Y:

X = 50% + 50% * sin(V * 3.6deg)
Y = 50% - 50% * cos(V * 3.6deg)

Our code becomes:

.sector {
  --v: 35; /* [0 100] */
  
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--v) * 3.6deg)) of 50%,
    line to center);
}

Hmm, the result is not good, but there are no mistakes in the code. Can you figure out what we are missing?

Itโ€™s the size and direction of the arc!

Remember what I told you in the last article? You will always have trouble with them, but if we try the different combinations, we can easily fix the issue. In our case, we need to use: small cw.

Better! Letโ€™s try it with more values and see how the shape behaves:

Oops, some values are good, but others not so much. The direction needs to be clockwise, but maybe we should use large instead of small? Letโ€™s try:

Still not working. The issue here is that we are moving one point of the arc based on the V value, and this movement creates a different configuration for the arc command.

Here is an interactive demo to better visualize what is happening:

When you update the value, notice how large cw always tries to follow the largest arc between the points, while small cw tries to follow the smallest one. When the value is smaller than 50, small cw gives us a good result. But when itโ€™s bigger than 50, the large cw combination is the good one.

I know, itโ€™s a bit tricky and I wanted to study this particular example to emphasize the fact that we can have a lot of headaches working with arcs. But the more issues we face, the better we get at fixing them.

The solution in this case is pretty simple. We keep the use of large cw and add a border-radius to the element. If you check the previous demo, you will notice that even if large cw is not producing a good result, itโ€™s filling the area we want. All we need to do is clip the extra space and a simple border-radius: 50% will do the job!

I am keeping the box-shadow in there so we can see the arc, but we can clearly see how border-radius is making a difference on the main shape.

There is still one edge case we need to consider. When the value is equal to 100, both points of the arc will have the same coordinates, which is logical since the sector is full and we have a circle. But when itโ€™s the case, the arc will do nothing by definition and we wonโ€™t get a full circle.

To fix this, we can limit the value to, for example, 99.99 to avoid reaching 100. Itโ€™s kind of hacky, but it does the job.

.sector {
  --v: 35; /* [0 100]*/
  
  --_v: min(99.99, var(--v));
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% large cw,
    line to center);
  border-radius: 50%;
}

Now our shape is perfect! And donโ€™t forget that you can apply it to image elements:

Arc shape

Similar to the sector shape, we can also create an arc shape. After all, we are working with the arc command, so we have to do it.

A series of three circular rings at various lengths.

We already have half the code since itโ€™s basically a sector shape without the inner part. We simply need to add more commands to cut the inner part.

Diagram showing the arc points of a semi-circle shape. There are two arcs, one on the outside and one on the inside. They are joined by straight lines.
.arc {
  --v: 35; 
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw large,
    
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) large
  );
  border-radius: 50%;
}

From the sector shape, we remove the line to center piece and replace it with another line command that moves to a point placed on the inner circle. If you compare its coordinates with the previous point, you will see an offset equal to --b, which is a variable that defines the arcโ€™s thickness. Then we draw an arc in the opposite direction (ccw) until the point 50% var(--b), which is also a point with an offset equal to --b from the top.

I am not defining the direction of the second arc since, by default, the browser will use ccw.

Ah, the same issue we hit with the sector shape is striking again! Not all the values are giving a good result due to the same logic we saw earlier, and, as you can see, border-radius is not fixing it. This time, we need to find a way to conditionally change the size of the arc based on the value. It should be large when V is bigger than 50, and small otherwise.

Conditions in CSS? Yes, itโ€™s possible! First, letโ€™s convert the V value like this:

--_f: round(down, var(--_v), 50)

The value is within the range [0 99.99] (donโ€™t forget that we donโ€™t want to reach the value 100). We use round() to make sure itโ€™s always equal to a multiple of a specific value, which is 50 in our case. If the value is smaller than 50, the result is 0, otherwise itโ€™s 50.

There are only two possible values, so we can easily add a condition. If --_f is equal to 0 we use small; otherwise, we use large:

.arc {
  --v: 35;
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  --_f: round(down,var(--_v), 50);
  --_c: if(style(--_f: 0): small; else: large);
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c),
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) var(--_c)
  );
}

I know what you are thinking, but let me tell you that the above code is valid. You probably donโ€™t know it yet, but CSS has recently introduced inline conditionals using an if() syntax. Itโ€™s still early to play with it, but we have found a perfect use case for it. Here is a demo that you can test using Chrome Canary:

Another way to express conditions is to rely on style queries that have better support:

.arc {
  --v: 35;
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  --_f: round(down, var(--_v), 50);
  aspect-ratio: 1;
  container-name: arc;
}
.arc:before {
  content: "";
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large)
  );
  @container style(--_f: 0) { --_c: small }
}

The logic is the same but, this feature requires a parent-child relation, which is why I am using a pseudo-element. By default, the size will be large, and if the value of --_f is equal to 0, we switch to small.

Note that we have to register the variable --_f using @property to be able to either use the if() function or style queries.

Did you notice another subtle change I have made to the shape? I removed border-radius and I applied the conditional logic to the first arc. Both have the same issue, but border-radius can fix only one of them while the conditional logic can fix both, so we can optimize the code a little.

Arc shape with rounded edges

What about adding rounded edges to our arc? Itโ€™s better, right?

A series of three semi-circles with rounded edges at varying lengths.

Can you see how itโ€™s done? Take it as a small exercise and update the code from the previous examples to add those rounded edges. I hope you are able to find it by yourself because the changes are pretty straightforward โ€” we update one line command with an arc command and we add another arc command at the end.

clip-path: shape(from top,
  arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
         calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
  arc to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
         calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)) of 1% cw,
  arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large),
  arc to top of 1% cw
);

If you do not understand the changes, get out a pen and paper, then draw the shape to better see the four arcs we are drawing. Previously, we had two arcs and two lines, but now we are working with arcs instead of lines.

And did you remember the trick of using a 1% value for the radius? The new arcs are half circles, so we can rely on that trick where you specify a tiny radius and the browser will do the job for you and find the correct value!

Conclusion

We are done โ€” enough about the arc command! I had to write two articles that focus on this command because itโ€™s the trickiest one, but I hope itโ€™s now clear how to use it and how to handle the direction and size thing, as that is probably the source of most headaches.

By the way, I have only studied the case of circular arcs because, in reality, we can specify two radii and draw elliptical ones, which is even more complex. Unless you want to become a shape() master, you will rarely need elliptical arcs, so donโ€™t bother yourself with them.

Until the next article, I wrote an article for Frontend Masters where you can create more fancy shapes using the arc command that is a good follow-up to this one.

Three shapes. The first looks like a flower. The second looks like a sun. The third looks like a blob.

Footnotes

(1) The arc command is defined to draw elliptical arcs by taking two radii, but if we define one radius value, it means that the vertical and horizontal radius will use that same value and we have circular arcs. When itโ€™s a length, itโ€™s trivial, but when we use percentages, the value will resolve against the direction-agnostic size, which is equal to the length of the diagonal of the box, divided by sqrt(2).

In our case, we have a square element so 50% of the direction-agnostic size will be equal to 50% of sqrt(Widthยฒ + Heightยฒ)/sqrt(2). And since both width and height are equal, we end with 50% of the width (or the height). โฎ‘


Better CSS Shapesย Using shape() โ€” Part 2: More on Arcs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Daniel Schwarz
Tue, 27 May 2025 13:02:32 +0000


The reading-flow and reading-order proposed CSS properties are designed to specify the source order of HTML elements in the DOM tree, or in simpler terms, how accessibility tools deduce the order of elements. Youโ€™d use them to make the focus order of focusable elements match the visual order, as outlined in the Web Content Accessibility Guidelines (WCAG 2.2).

To get a better idea, letโ€™s just dive in!

(Oh, and make sure that youโ€™re using Chrome 137 or higher.)

reading-flow

reading-flow determines the source order of HTML elements in a flex, grid, or block layout. Again, this is basically to help accessibility tools provide the correct focus order to users.

The default value is normal (so, reading-flow: normal). Other valid values include:

  • flex-visual
  • flex-flow
  • grid-rows
  • grid-columns
  • grid-order
  • source-order

Letโ€™s start with the flex-visual value. Imagine a flex row with five links. Assuming that the reading direction is left-to-right (by the way, you can change the reading direction with the direction CSS property), thatโ€™d look something like this:

Now, if we apply flex-direction: row-reverse, the links are displayed 5-4-3-2-1. The problem though is that the focus order still starts from 1 (tab through them!), which is visually wrong for somebody that reads left-to-right.

But if we also apply reading-flow: flex-visual, the focus order also becomes 5-4-3-2-1, matching the visual order (which is an accessibility requirement!):

<div>
  <a>1</a>
  <a>2</a>
  <a>3</a>
  <a>4</a>
  <a>5</a>
</div>
div {
  display: flex;
  flex-direction: row-reverse;
  reading-flow: flex-visual;
}

To apply the default flex behavior, reading-flow: flex-flow is what youโ€™re looking for. This is very akin to reading-flow: normal, except that the container remains a reading flow container, which is needed for reading-order (weโ€™ll dive into this in a bit).

For now, letโ€™s take a look at the grid-y values. In the grid below, the grid items are all jumbled up, and so the focus order is all over the place.

We can fix this in two ways. One way is that reading-flow: grid-rows will, as youโ€™d expect, establish a row-by-row focus order:

<div>
  <a>1</a>
  <a>2</a>
  <a>3</a>
  <a>4</a>
  <a>5</a>
  <a>6</a>
  <a>7</a>
  <a>8</a>
  <a>9</a>
  <a>10</a>
  <a>11</a>
  <a>12</a>
</div>
div {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-auto-rows: 100px;
  reading-flow: grid-rows;
  
  a:nth-child(2) {
    grid-row: 2 / 4;
    grid-column: 3;
  }

  a:nth-child(5) {
    grid-row: 1 / 3;
    grid-column: 1 / 3;
  }
}

Or, reading-flow: grid-columns will establish a column-by-column focus order:

reading-flow: grid-order will give us the default grid behavior (i.e., the jumbled up version). This is also very akin to reading-flow: normal (except that, again, the container remains a reading flow container, which is needed for reading-order).

Thereโ€™s also reading-flow: source-order, which is for flex, grid, and block containers. It basically turns containers into reading flow containers, enabling us to use reading-order. To be frank, unless Iโ€™m missing something, this appears to make the flex-flow and grid-order values redundant?

reading-order

reading-order sort of does the same thing as reading-flow. The difference is that reading-order is for specific flex or grid items, or even elements in a simple block container. It works the same way as the order property, although I suppose we could also compare it to tabindex.

Note: To use reading-order, the container must have the reading-flow property set to anything other than normal.

Iโ€™ll demonstrate both reading-order and order at the same time. In the example below, we have another flex container where each flex item has the order property set to a different random number, making the order of the flex items random. Now, weโ€™ve already established that we can use reading-flow to determine focus order regardless of visual order, but in the example below weโ€™re using reading-order instead (in the exact same way as order):

div {
  display: flex;
  reading-flow: source-order; /* Anything but normal */

  /* Features at the end because of the higher values */
  a:nth-child(1) {
    /* Visual order */
    order: 567;
    /* Focus order */
    reading-order: 567;
  }

  a:nth-child(2) {
    order: 456;
    reading-order: 456;
  }

  a:nth-child(3) {
    order: 345;
    reading-order: 345;
  }

  a:nth-child(4) {
    order: 234;
    reading-order: 234;
  }

  /* Features at the beginning because of the lower values */
  a:nth-child(5) {
    order: -123;
    reading-order: -123;
  }
}

Yes, those are some rather odd numbers. Iโ€™ve done this to illustrate how the numbers donโ€™t represent the position (e.g., order: 3 or reading-order: 3 doesnโ€™t make it third in the order). Instead, elements with lower numbers are more towards the beginning of the order and elements with higher numbers are more towards the end. The default value is 0. Elements with the same value will be ordered by source order.

In practical terms? Consider the following example:

div {
  display: flex;
  reading-flow: source-order;

  a:nth-child(1) {
    order: 1;
    reading-order: 1;
  }

  a:nth-child(5) {
    order: -1;
    reading-order: -1;
  }
}

Of the five flex items, the first one is the one with order: -1 because it has the lowest order value. The last one is the one with order: 1 because it has the highest order value. The ones with no declaration default to having order: 0 and are thus ordered in source order, but otherwise fit in-between the order: -1 and order: 1 flex items. And itโ€™s the same concept for reading-order, which in the example above mirrors order.

However, when reversing the direction of flex items, keep in mind that order and reading-order work a little differently. For example, reading-order: -1 would, as expected, and pull a flex item to the beginning of the focus order. Meanwhile, order: -1 would pull it to the end of the visual order because the visual order is reversed (so weโ€™d need to use order: 1 instead, even if that doesnโ€™t seem right!):

div {
  display: flex;
  flex-direction: row-reverse;
  reading-flow: source-order;

  a:nth-child(5) {
    /* Because of row-reverse, this actually makes it first */
    order: 1;
    /* However, this behavior doesnโ€™t apply to reading-order */
    reading-order: -1;
  }
}

reading-order overrides reading-flow. If we, for example, apply reading-flow: flex-visual, reading-flow: grid-rows, or reading-flow: grid-columns (basically, any declaration that does in fact change the reading flow), reading-order overrides it. We could say that reading-order is applied after reading-flow.

What if I donโ€™t want to use flexbox or grid layout?

Well, that obviously rules out all of the flex-y and grid-y reading-flow values; however, you can still set reading-flow: source-order on a block element and then manipulate the focus order with reading-order (as we did above).

How does this relate to the tabindex HTML attribute?

Theyโ€™re not equivalent. Negative tabindex values make targets unfocusable and values other than 0 and -1 arenโ€™t recommended, whereas a reading-order declaration can use any number as itโ€™s only contextual to the reading flow container that contains it.

For the sake of being complete though, I did test reading-order and tabindex together and reading-order appeared to override tabindex.

Going forward, Iโ€™d only use tabindex (specifically, tabindex="-1") to prevent certain targets from being focusable (the disabled attribute will be more appropriate for some targets though), and then reading-order for everything else.

Closing thoughts

Being able to define reading order is useful, or at least it means that the order property can finally be used as intended. Up until now (or rather when all web browsers support reading-flow and reading-order, because they only work in Chrome 137+ at the moment), order hasnโ€™t been useful because we havenโ€™t been able to make the focus order match the visual order.


What We Know (So Far) About CSS Reading Order originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Chris Coyier
Mon, 26 May 2025 15:54:28 +0000


This is a great story from Dan North about “The Worst Programmer I know”, Tim MacKinnon. It’s a story about measuring developer performance with metrics:

We were working for a well-known software consultancy at a Big Bank that decided to introduce individual performance metrics, โ€œfor appraisal and personal development purposesโ€. This was cascaded through the organisation, and landed in our team in terms of story points delivered. This was after some considered discussion from the department manager, who knew you shouldnโ€™t measure things like lines of code or bugs found, because people can easily game these.

Scared? Maybe you can guess. Tim was very bad at metrics.

Timโ€™s score was consistently zero. Zero! Not just low, or trending downwards, but literally zero. Week after week, iteration after iteration. Zero points for Tim.

Why? Maybe you can guess again. Tim wasn’t playing that game, he was a true senior developer in the sense that he nurtured his team.

… he would spend his day pairing with different teammates. With less experienced developers he would patiently let them drive whilst nudging them towards a solution. He would not crowd them or railroad them, but let them take the time to learn whilst carefully crafting moments of insight and learning

Tim wasnโ€™t delivering software; Tim was delivering a team that was delivering software.

Every organization is different though. Mercifully in the situation above, Dan protected Tim. But we can all imagine a situation where Tim was fired because of this silly measurement system. (That always reminds me of Cathy O’Neils Weapons of Math Destruction). Getting to know how the organization works, so you can work within it, is another approach that Cindy Sridharan advocates for. See: How to become a more effective engineer.

You can either complain and pontificate on Twitter on how the tech industry *should* ideally work, or you can learn how your org *really* works and whatโ€™s rewarded, and optimize for that.

Different organizations will have different paths to these answers. I’ll pluck off a few bullet points:

  • exactly what technical skill you need to invest effort into getting better at, which will actually be rewarded
  • how to build lasting relationships with other people on your team or organization that ultimately dictate the success of a project
  • how to effectively pitch projects or improvements to leadership and actually see these through to completion
  • how to weigh the pros and cons of technical choices in the larger context of the organizational realities and needs
  • how to discern whatโ€™s achievable, and in precisely what time frame

Figure out who matters, what they care about, and how to effectively get things done. And don’t wait!

To build credibility, you need to demonstrate some impact early on, instead of waiting months to get the lie of the land before you start getting anything done. Chasing small wins and low-hanging fruit can be an easy path to productivity. Donโ€™t underestimate their importance.

Another one I like in this realm of being a truly effective developer is Artem Zakirullin’s Cognitive load is what matters. A good developer can write code that themselves and others can read without being so complex that, well, you run out of mental capacity.

When reading code, you put things like values of variables, control flow logic and call sequences into your head. The average person can hold roughly four such chunks in working memory. Once the cognitive load reaches this threshold, it becomes much harder to understand things.

That tracks for me. I start tracing how code works, and I’m all good and have it in my head, then it feels like right at the fifth logical jump, my brain just dumps it all out and I’m lost.

I suspect it’s a talent of really great programmers that they can hold a bit more in their head, but it’s not smart to assume that of your fellow developers. And remember that even the very smart appreciate things that are very simple and clear, perhaps especially.

You know what strikes me as a smart developer move? When they work together even across organizations. It’s impressive to me to see Standard Schema an effort by all the people who work on any library that deals with JavaScript/TypeScript schemas to make them easier to use and implement.

The goal is to make it easier for ecosystem tools to accept user-defined type validators, without needing to write custom logic or adapters for each supported library. And since Standard Schema is a specification, they can do so with no additional runtime dependencies. Integrate once, validate anywhere.

There are heaps of libraries and tools that already support it, so I’d call that a big success. I see Zod released Mini recently, which uses functions instead of methods, making it tree-shakable, but otherwise works exactly the same. Likely a nod to Validbot which was always the “Zod but smaller” choice.

Another thing I think is smart: seeing what developers are already doing and making that thing better. Like, I’m sure there are very fancy exotic ways to debug JavaScript in powerful ways. But we all know most of us just console.log() stuff. So I like how Microsoft is like, let’s just make that better with console.context(). This allows for better filtering and styling of messages and such, which would surely be welcome. Might as well steal formatting strings from Node as well.

by: Temani Afif
Fri, 23 May 2025 13:02:32 +0000


Creating CSS Shapes is a classic and one of my favorite exercise. Indeed, I have one of the biggest collections of CSS Shapes from where you can easily copy the code of any shape. I also wrote an extensive guide on how to create them: The Modern Guide For Making CSS Shapes.

Even if I have detailed most of the modern techniques and tricks, CSS keeps evolving, and new stuff always emerges to simplify our developer life. Recently, clip-path was upgraded to have a new shape() value. A real game changer!

Before we jump in, it’s worth calling out that the shape() function is currently only supported in Chrome 137+ and Safari 18.4+ as I’m writing this in May 2025.

What is shape()?

Let me quote the description from the official specification:

While the path() function allows reuse of the SVG path syntax to define more arbitrary shapes than allowed by more specialized shape functions, it requires writing a path as a single string (which is not compatible with, for example, building a path piecemeal with var()), and inherits a number of limitations from SVG, such as implicitly only allowing the px unit.

The shape() function uses a set of commands roughly equivalent to the ones used by path(), but does so with more standard CSS syntax, and allows the full range of CSS functionality, such as additional units and math functions.

In other words, we have the SVG features in the CSS side that we can combine with existing features such as var(), calc(), different units, etc. SVG is already good at drawing complex shapes, so imagine what is possible with something more powerful.

If you keep reading the spec, you will find:

In that sense, shape() is a superset of path(). A path() can be easily converted to a shape(), but to convert a shape() back to a path() or to SVG requires information about the CSS environment.

And guess what? I already created an online converter from SVG to CSS. Save this tool because it will be very handy. If you are already good at creating SVG shapes or you have existing codes, no need to reinvent the wheel. You paste your code in the generator, and you get the CSS code that you can easily tweak later.

Letโ€™s try with the CSS-Tricks logo. Here is the SVG I picked from the website:

<svg width="35px" height="35px" viewBox="0 0 362.62 388.52" >
  <path d="M156.58,239l-88.3,64.75c-10.59,7.06-18.84,11.77-29.43,11.77-21.19,0-38.85-18.84-38.85-40C0,257.83,14.13,244.88,27.08,239l103.6-44.74L27.08,148.34C13,142.46,0,129.51,0,111.85,0,90.66,18.84,73,40,73c10.6,0,17.66,3.53,28.25,11.77l88.3,64.75L144.81,44.74C141.28,20,157.76,0,181.31,0s40,18.84,36.5,43.56L206,149.52l88.3-64.75C304.93,76.53,313.17,73,323.77,73a39.2,39.2,0,0,1,38.85,38.85c0,18.84-12.95,30.61-27.08,36.5L231.93,194.26,335.54,239c14.13,5.88,27.08,18.83,27.08,37.67,0,21.19-18.84,38.85-40,38.85-9.42,0-17.66-4.71-28.26-11.77L206,239l11.77,104.78c3.53,24.72-12.95,44.74-36.5,44.74s-40-18.84-36.5-43.56Z"></path>
</svg>

You take the value inside the d attribute, paste it in the converter, and boom! You have the following CSS:

.shape {
  aspect-ratio: 0.933;
  clip-path: shape(from 43.18% 61.52%,line by -24.35% 16.67%,curve by -8.12% 3.03% with -2.92% 1.82%/-5.2% 3.03%,curve by -10.71% -10.3% with -5.84% 0%/-10.71% -4.85%,curve to 7.47% 61.52% with 0% 66.36%/3.9% 63.03%,line by 28.57% -11.52%,line to 7.47% 38.18%,curve to 0% 28.79% with 3.59% 36.67%/0% 33.33%,curve to 11.03% 18.79% with 0% 23.33%/5.2% 18.79%,curve by 7.79% 3.03% with 2.92% 0%/4.87% 0.91%,line by 24.35% 16.67%,line to 39.93% 11.52%,curve to 50% 0% with 38.96% 5.15%/43.51% 0%,smooth by 10.07% 11.21% with 11.03% 4.85%,line to 56.81% 38.48%,line by 24.35% -16.67%,curve to 89.29% 18.79% with 84.09% 19.7%/86.36% 18.79%,arc by 10.71% 10% of 10.81% 10.09% small cw,curve by -7.47% 9.39% with 0% 4.85%/-3.57% 7.88%,line to 63.96% 50%,line to 92.53% 61.52%,curve by 7.47% 9.7% with 3.9% 1.51%/7.47% 4.85%,curve by -11.03% 10% with 0% 5.45%/-5.2% 10%,curve by -7.79% -3.03% with -2.6% 0%/-4.87% -1.21%,line to 56.81% 61.52%,line by 3.25% 26.97%,curve by -10.07% 11.52% with 0.97% 6.36%/-3.57% 11.52%,smooth by -10.07% -11.21% with -11.03% -4.85%,close);
}

Note that you donโ€™t need to provide any viewBox data. The converter will automatically find the smallest rectangle for the shape and will calculate the coordinates of the points accordingly. No more viewBox headaches and no need to fight with overflow or extra spacing!

Here is another example where I am applying the shape to an image element. I am keeping the original SVG so you can compare both shapes.

When to use shape()

I would be tempted to say โ€œall the timeโ€ but in reality, not. In my guide, I distinguish between two types of shapes: The ones with only straight lines and the ones with curves. Each type can either have repetition or not. In the end, we have four categories of shapes.

Two by two grid of shapes comparing those with and without curves and those with and without repetition.

If we donโ€™t have curves and we donโ€™t have repetition (the easiest case), then clip-path: polygon() should do the job. If we have a repetition (with or without curves), then mask is the way to go. With mask, we can rely on gradients that can have a specific size and repeat, but with clip-path we donโ€™t have such options.

If you have curves and donโ€™t have a repetition, the new shape() is the best option. Previously, we had to rely on mask since clip-path is very limited, but thatโ€™s no longer the case. Of course, these are not universal rules, but my own way to identify which option is the most suitable. At the end of the day, itโ€™s always a case-by-case basis as we may have other things to consider, such as the complexity of the code, the flexibility of the method, browser support, etc.

Letโ€™s draw some shapes!

Enough talking, letโ€™s move to the interesting part: drawing shapes. I will not write a tutorial to explain the โ€œcomplexโ€ syntax of shape(). It will be boring and not interesting. Instead, we will draw some common shapes and learn by practice!

Rectangle

Take the following polygon:

clip-path: polygon(
  0 0,
  100% 0,
  100% 100%,
  0 100%
);

Technically, this will do nothing since it will draw a rectangle that already follows the element shape which is a rectangle, but itโ€™s still the perfect starting point for us.

Now, letโ€™s write it using shape().

clip-path: shape(
  from 0 0,
  line to 100% 0,
  line to 100% 100%,
  line to 0 100%
);

The code should be self-explanatory and we already have two commands. The from command is always the first command and is used only once. It simply specifies the starting point. Then we have the line command that draws a line to the next point. Nothing complex so far.

We can still write it differently like below:

clip-path: shape(
  from 0 0,
  hline to 100%,
  vline to 100%,
  hline to 0
);

Between the points 0 0 and 100% 0, only the first value is changing which means we are drawing a horizontal line from 0 0 to 100% 0, hence the use of hline to 100% where you only need to specify the horizontal offset. Itโ€™s the same logic using vline where we draw a vertical line between 100% 0 and 100% 100%.

I wonโ€™t advise you to draw your shape using hline and vline because they can be tricky and are a bit difficult to read. Always start by using line and then if you want to optimize your code you can replace them with hline or vline when applicable.

We have our first shape and we know the commands to draw straight lines:

Circular Cut-Out

Now, letโ€™s try to add a circular cut-out at the top of our shape:

A square shape with a scalloped half circle cut out of the top.

For this, we are going to rely on the arc command, so letโ€™s understand how it works.

Diagram showing two intersecting circles with points indicating where they cross and arrows indicating the large and small clockwise and counterclockwise directions.

If we have two points, A and B, there are exactly two circles with a given radius that intersect with both points like shown in the figure. The intersection gives us four possible arcs we can draw between points A and B. Each arc is defined by a size and a direction.

There is also the particular case where the radius is equal to half the distance between A and B. In this case, only two arcs can be drawn and the direction will decide which one.

A circle with two points at the top and bottom highlighted to show movement in the clockwise and counterclockwise directions.

The syntax will look like this:

clip-path: shape(
  from Xa Ya, 
  arc to Xb Yb of R [large or small] [cw or ccw]
);

Letโ€™s add this to our previous shape. No need to think about the values. Instead, letโ€™s use random ones and see what happens:

clip-path: shape(
  from 0 0,
  arc to 40% 0 of 50px,
  line to 100% 0,
  line to 100% 100%,
  line to 0 100%
);

Not bad, we can already see the arc between 0 0 and 40% 0. Notice how I didnโ€™t define the size and direction of the arc. By default, the browser will use small and ccw.

Letโ€™s explicitly define the size and direction to see the four different cases:

Hmm, itโ€™s working for the first two blocks but not the other ones. Quite strange, right?

Actually, everything is working fine. The arcs are drawn outside the element area so nothing is visible. If you add some box-shadow, you can see them:

Arcs can be tricky due to the size and direction thing, so get ready to be confused. If that happens, remember that you have four different cases, and trying all of them will help you find which one you need.

Now letโ€™s try to be accurate and draw half a circle with a specific radius placed at the center:

A rectangular shape with a scalloped half circle cut out of the top. Arrows indicate the circle's radius.

We can define the radius as a variable and use what we have learned so far:

.shape {
  --r: 50px;

  clip-path: shape(
    from 0 0, 
    line to calc(50% - var(--r)) 0, 
    arc to calc(50% + var(--r)) 0 of var(--r), 
    line to 100% 0, 
    line to 100% 100%, 
    line to 0 100%
  );
}

Itโ€™s working fine, but the code can still be optimized. We can replace all the line commands with hline and vline like below:

.shape {
  --r: 50px;

  clip-path: shape(from 0 0, 
    hline to calc(50% - var(--r)), 
    arc to calc(50% + var(--r)) 0 of var(--r), 
    hline to 100%, 
    vline to 100%, 
    hline to 0
  );
}

We can also replace the radius with 1%:

.shape {
  --r: 50px;

  clip-path: shape(from 0 0, 
    hline to calc(50% - var(--r)), 
    arc to calc(50% + var(--r)) 0 of 1%,
    hline to 100%, 
    vline to 100%, 
    hline to 0
  );
}

When you define a small radius (smaller than half the distance between both points), no circle can meet the condition we explained earlier (an intersection with both points), so we cannot draw an arc. This case falls within an error handling where the browser will scale the radius until we can have a circle that meets the condition. Instead of considering this case as invalid, the browser will fix โ€œour mistakeโ€ and draw an arc.

This error handling is pretty cool as it allows us to simplify our shape() function. Instead of specifying the exact radius, I simply put a small value and the browser will do the job for me. This trick only works when the arc we want to draw is half a circle. Donโ€™t try to apply it with any arc command because it wonโ€™t always work.

Another optimization is to update the following:

arc to calc(50% + var(--r)) 0 of 1%,

โ€ฆwith this:

arc by calc(2 * var(--r)) 0 of 1%,

Almost all the commands can either use a to directive or a by directive. The first one defines absolute coordinates like the one we use with polygon(). Itโ€™s the exact position of the point we are moving to. The second defines relative coordinates which means we need to consider the previous point to identify the coordinates of the next point.

In our case, we are telling the arc to consider the previous point (50% - R) 0 and move by 2*R 0, so the final point will be (50% - R + 2R) (0 + 0), which is the same as (50% + R) 0.

.shape {
  --r: 50px;

  clip-path: shape(from 0 0, 
    hline to calc(50% - var(--r)), 
    arc by calc(2 * var(--r)) 0 of 1px, 
    hline to 100%, 
    vline to 100%, 
    hline to 0
  );
}

This last optimization is great because if we want to move the cutout from the center, we only need to update one value: the 50%.

.shape {
  --r: 50px;
  --p: 40%;

  clip-path: shape(
    from 0 0, 
    hline to calc(var(--p) - var(--r)),
    arc by calc(2 * var(--r)) 0 of 1px, 
    hline to 100%, 
    vline to 100%, 
    hline to 0
  );
}

How would you adjust the above to have the cut-out at the bottom, left, or right? Thatโ€™s your first homework assignment! Try to do it before moving to the next part.

I will give my implementation so that you can compare with yours, but donโ€™t cheat! If you can do this without referring to my work, you will be able to do more complex shapes more easily.

Rounded Tab

Enough cut-out, letโ€™s try to create a rounded tab:

A rectangular tab shape with rounded corners on the top and a flat, hard edge across the bottom. The words 'Rounded tab' are in white inside the rectangle.

Can you see the puzzle of this one? Similar to the previous shape, itโ€™s a bunch of arc and line commands. Here is the code:

.shape {
  --r: 26px;
  
  clip-path: shape(
    /* left part */
    from 0 100%,
    arc by var(--r) calc(-1 * var(--r)) of var(--r),
    vline to var(--r),
    arc by var(--r) calc(-1 * var(--r)) of var(--r) cw,
    /* right part */
    hline to calc(100% - 2 * var(--r)),
    arc by var(--r) var(--r) of var(--r) cw,
    vline to calc(100% - var(--r)),
    arc by var(--r) var(--r) of var(--r)
  );
}

It looks a bit scary, but if you follow it command by command, it becomes a lot clearer to see whatโ€™s happening. Here is a figure to help you visualize the left part of it.

Diagram of the left side of a rounded rectangular tab, showing the rounded edge's radius and the arcs that are used to make it.

All the arc commands are using the by directive because, in all the cases, I always need to move by an offset equal to R, meaning I donโ€™t have to calculate the coordinates of the points. And donโ€™t try to replace the radius by 1% because it wonโ€™t work since we are drawing a quarter of a circle rather than half of a circle.

From this, we can easily achieve the left and right variations:

Notice how I am only using two arc commands instead of three. One rounded corner can be done with a classic border radius, so this can help us simplify the shape.

Inverted Radius

One last shape, the classic inner curve at the corner also called an inverted radius. How many arc commands do we need for this one? Check the figure below and think about it.

A square with rounded edges and a a circlular arc cut out of the top-right corner of the shape.

If your answer is six, you have chosen the difficult way to do it. Itโ€™s logical to think about six arcs since we have six curves, but three of them can be done with a simple border radius, so only three arcs are needed. Always take the time to analyze the shape you are creating. Sometimes, basic CSS properties can help with creating the shape.

What are you waiting for? This is your next homework and I wonโ€™t help you with a figure this time. You have all that you need to easily create it. If you are struggling, give the article another read and try to study the previous shapes more in depth.

Here is my implementation of the four variations:

Conclusion

Thatโ€™s all for this first part. You should have a good overview of the shape() function. We focused on the line and arc commands which are enough to create most of the common shapes.

Donโ€™t forget to bookmark the SVG to CSS converter and keep an eye on my CSS Shape collection where you can find the code of all the shapes I create. And here is a last shape to end this article.


Better CSS Shapes Using shape() โ€” Part 1: Lines and Arcs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Geoff Graham
Thu, 22 May 2025 14:43:09 +0000


Clever, clever that Andy Bell. He shares a technique for displaying image alt text when the image fails to load. Well, more precisely, it’s a technique to apply styles to the alt when the image doesn’t load, offering a nice UI fallback for what would otherwise be a busted-looking error.

The recipe? First, make sure you’re using alt in the HTML. Then, a little JavaScript snippet that detects when an image fails to load:

const images = document.querySelectorAll("img");

if (images) {
  images.forEach((image) => {
    image.onerror = () => {
      image.setAttribute("data-img-loading-error", "");
    };
  });
}

That slaps an attribute on the image โ€” data-img-loading-error โ€” that is selected in CSS:

img[data-img-loading-error] {
  --img-border-style: 0.25em solid
    color-mix(in srgb, currentColor, transparent 75%);
  --img-border-space: 1em;

  border-inline-start: var(--img-border-style);
  border-block-end: var(--img-border-style);
  padding-inline-start: var(--img-border-space);
  padding-block: var(--img-border-space);
  max-width: 42ch;
  margin-inline: auto;
}

And what you get is a lovely little presentation of the alt that looks a bit like a blockquote and is is only displayed when needed.

Andy does note, however, that Safari does not render alt text if it goes beyond a single line, which 🤷‍♂️.


You can style alt text like any other text originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Geoff Graham
Wed, 21 May 2025 15:09:29 +0000


Shape master Temani Afif has what might be the largest collection of CSS shapes on the planet with all the tools to generate them on the fly. There’s a mix of clever techniques he’s typically used to make those shapes, many of which he’s covered here at CSS-Tricks over the years.

Some of the more complex shapes were commonly clipped with the path() function. That makes a lot of sense because it literally accepts SVG path coordinates that you can draw in an app like Figma and export.

But Temani has gone all-in on the newly-released shape() function which recently rolled out in both Chromium browsers and Safari. That includes a brand-new generator that converts path() shapes in shape() commands instead.

So, if we had a shape that was originally created with an SVG path, like this:

.shape {
  clip-path: path(
    M199.6,
    18.9c-4.3-8.9-12.5-16.4-22.3-17.8c-11.9-1.7-23.1,
    5.4-32.2,
    13.2c-9.1,
    7.8-17.8,
    16.8-29.3,
    20.3c-20.5,
    6.2-41.7-7.4-63.1-7.5c38.7,
    27,
    24.8,
    33,
    15.2,
    43.3c-35.5,
    38.2-0.1,
    99.4,
    40.6,
    116.2c32.8,
    13.6,
    72.1,
    5.9,
    100.9-15c27.4-19.9,
    44.3-54.9,
    47.4-88.6c0.2-2.7,
    0.4-5.3,
    0.5-7.9c204.8,
    38,
    203.9,
    27.8,
    199.6,
    18.9z
  );
}

…the generator will spit this out:

.shape {
  clip-path: shape(
    from 97.54% 10.91%,
    curve by -10.93% -10.76% with -2.11% -5.38%/-6.13% -9.91%,
    curve by -15.78% 7.98% with -5.83% -1.03%/-11.32% 3.26%,
    curve by -14.36% 12.27% with -4.46% 4.71%/-8.72% 10.15%,
    curve by -30.93% -4.53% with -10.05% 3.75%/-20.44% -4.47%,
    curve to 7.15% 25.66% with 18.67% 15.81%/11.86% 19.43%,
    curve by 19.9% 70.23% with -17.4% 23.09%/-0.05% 60.08%,
    curve by 49.46% -9.07% with 16.08% 8.22%/35.34% 3.57%,
    curve by 23.23% -53.55% with 13.43% -12.03%/21.71% -33.18%,
    curve by 0.25% -4.77% with 0.1% -1.63%/0.2% -3.2%,
    curve to 97.54% 10.91% with 100.09% 22.46%/99.64% 16.29%,
    close
  );
}

Pretty cool!

Honestly, I’m not sure how often I’ll need to convert path() to shape(). Seems like a stopgap sorta thing where the need for it dwindles over time as shape() is used more often โ€” and it’s not like the existing path() function is broken or deprecated… it’s just different. But still, I’m using the generator a LOT as I try to wrap my head around shape() commands. Seeing the commands in context is invaluable which makes it an excellent learning tool.


SVG to CSS Shape Converter originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Chris Coyier
Mon, 19 May 2025 16:36:15 +0000


I admit I’m a sucker for “do this; don’t do that” (can’t you read the sign) blog posts when it comes to design. Screw nuance, gimme answers. Anthony Hobday has a pretty good one in Visual design rules you can safely follow every time.

Two stacked frames illustrating depth perception: the left frame shows a darkening gradient as it approaches, while the right frame displays a lightening gradient.
Makes sense to me; ship it.

Erik Kennedy does a pretty good job with posts in this vein, and I just read one about designing sidebars in his email newsletter. But he didn’t blog it so I can’t link to it. Instead I’ll link to his 15 Tips for Better Signupโ€Š/โ€ŠLogin UX which is the same kinda “do this” advice.

A user interface mockup showing a 'Create account' form with labeled fields for 'Email' and 'Password'. An on-screen keyboard is displayed below, indicating that the email field should be set to type=email to trigger a specialized mobile keyboard.
I perish each time I have to hunt manually for the @

Jon Yablonski’s Laws of UX site is a pretty good example of this too, except the “do this” advice is more like “think about this principle”. They are pretty straightforward though, like:

An infographic about choice overload, featuring a grid of colored circles and a definition explaining the concept as the tendency to feel overwhelmed by too many options, also known as the paradox of choice.

Welp now that we’ve linked up a bunch of design related stuff I’d better keep going. My pockets are always hot with links. I’m like and old man except instead of Wether’s Originals I have great URLs.

If I had to design some shirts and hoodies and coats and stuff, I’d definitely want some clean templates to use, so I think these 45 fully editable apparel templates from atipo is pretty sweet. (โ‚ฌ30.00 with a bunch of fonts too)

Not Boring software rules. They have some really nice apps that feel very designed. I use their Habits app every day. They’ve got a nice blog post on the role of sound in software design. It’s common to think that software that makes any sound is a mistake as it’s just obnoxious or superfluous. I like this one:

4. Shape a feeling.ย Sound can play a key role in queueing us in on how we should feel in a momentโ€”happy, reflective, alert. In the todo appย Clear, successively checking things off a list plays a rising set of notes that builds a sense of accomplishment.

Is “good” and “bad” web design subjective (like just our opinions) or objective (provable with data)? I mean, that’s subjective, lol. Remy Sharp was thinking about it recently and has developed his own set of criteria. A simple one:

Is the main content theย mainย content item?ย Put another way, is the content hidden (or fighting) clutter on the page?

Seems simple, but… not always. I was reviewing a site recently and the homepage had just a zillion things it was trying to say. It was a store, so there were locations, an invite to search, an invite to call, and invite to chat, discounts available, a current promotion, financing available, categories for browsing, upcoming events, etc, etc. The thing is, it’s easy to point at it and say Mess! โ€” all that stuff is there because it’s important to somebody in the organization. Deciding on what even “the content” is can be tricky. I always think the homepage probably isn’t the best place to start a big debate like this. Clean up the more focused pages first.

Let’s end with something beautiful, like these flowing WebGL gradients by Alex Harri. I loved the mathematical intro on doing all this pixel by pixel work manually, then how to shift the responsibility of that work:

But consider the amount of work that needs to be done. Aย 1,000โœ•300 canvas, for example,ย containsย 300,000 pixels. Thatโ€™s 300,000 invocations of our pixel function every frame โ€” a ton of work for a CPU to perform 60 times a second! This is where WebGL comes in.

Shaders are a real journey, but I bet if you read every word of this post closely you’d be well on your way.

by: Juan Diego Rodrรญguez
Mon, 19 May 2025 12:32:22 +0000


A couple of days back, among the tens of crypto-scams that flood our contact inbox, we found an interesting question on nested lists from one of our readers.

I have a problem (related to list-numbering) that seems commonplace, but I can’t seem to solve it or find any solution for. If any of your geniuses can answer this, I’m sure there are going to be a lot of people interested.

Styling lists? Enough to catch my attention. After all, I just completed an entire guide about CSS counters. The message continues:

Here’s the problem. It’s a routine numbering sequence, of different levels, found in (for example) [government], legislation, and in my case, condominium bylaws. I have five levels represented by the first number at each level of 1., (1), (a) (lower-alpha), (i) (lower-roman), (A) (upper-alpha). Of course, I have 5 levels here, but if you could demonstrate a solution for 3 levels.

Fair enough! So, what we are looking to achieve is a nested list, where each sublist marker/counter is of a different kind. The example linked in the message is the following:

8 The strata corporation must repair and maintain all of the following:
    (a) common assets of the strata corporation;
    (b) common property that has not been designated as limited common property;
    (c) limited common property, but the duty to repair and maintain it is restricted to
        (i) repair and maintenance that in the ordinary course of events occurs less often than once a year, and
        (ii) the following, no matter how often the repair or maintenance ordinarily occurs:
            (A) the structure of a building;
            (B) the exterior of a building;
            (C) chimneys, stairs, balconies and other things attached to the exterior of a building;
            (D) doors, windows and skylights on the exterior of a building or that front on the common property;

While simple at first glance, it still has some nuance, so let’s try to come up with the most maintainable solution here.

The ugly way

My first approach to this problem was no approach at all; I just opened CodePen, wrote up the HTML, and tried to get my CSS to work towards the final result. After translating the Markdown into ol and li elements, and with no special styling on each list, the base list would look like the following:

Once there, my first instinct was to select each ol element and then change its list-style-type to the desired one. To target each level, I selected each ol depending on its number of ol ancestors, then let the specificity handle the rest:

ol {
  list-style-type: decimal; /* Unnecessary; just for demo */
}

ol ol {
  list-style-type: lower-alpha;
}

ol ol ol {
  list-style-type: lower-roman;
}

ol ol ol ol {
  list-style-type: upper-alpha;
}

And as you can see, this works… But we can agree it’s an ugly way to go about it.

Nesting to the rescue

Luckily, CSS nesting has been baseline for a couple of years now, so we could save ourselves a lot of ol selectors by just nesting each element inside the next one.

ol {
  list-style-type: decimal;
  
  ol {
    list-style-type: lower-alpha;
     
    ol {
      list-style-type: lower-roman;
          
       ol {
        list-style-type: upper-alpha;
      }
    }
  }
}

While too much nesting is usually frowned upon, I think that, for this case in particular, it makes the CSS clearer on what it intends to do โ€” especially since the CSS structure matches the HTML itself, and it also keeps all the list styles in one place. All to the same result:

I don’t know anything about legal documents, nor do I intend to learn about them. However, I do know the law, and by extension, lawyers are finicky about how they are formatted because of legal technicalities and whatnot. The point is that for a legal document, those parentheses surrounding each list marker โ€” likeย (A)ย orย (ii) โ€” are more than mere decoration and have to be included in our lists, which our current solution doesn’t.

A couple of years back, we would have needed to set a counter for each list and then include the parentheses along the counter() output; repetitive and ugly. Nowadays, we can use the @counter-style at rule, which as its name implies, allows us to create custom counter styles that can be used (among other places) in the list-style-type property.

In case you’re unfamiliar with the @counter-style syntax, what we need to know is that it can be used to extend predefined counter styles (like decimal or upper-alpha), and attach to them a different suffix or prefix. For example, the following counter style extends the common decimal style and adds a dash (-) as a prefix and a colon (:) as a suffix.

@counter-style my-counter-style {
  system: extends decimal;
  prefix: "- ";
  suffix: ": ";
}

ol {
  list-style-type: my-counter-style;
}

In our case, we’ll need four counter styles:

  • A decimal marker, without the ending dot. The initial submission doesn’t make it clear if it’s with or without the dot, but let’s assume it’s without.
  • A lower alpha marker, enclosed in parentheses.
  • A lower Roman marker, also enclosed in parentheses.
  • An upper alpha marker, enclosed in parentheses as well.

All these would translate to the following @counter-style rules:

@counter-style trimmed-decimal {
  system: extends decimal;
  suffix: " ";
}

@counter-style enclosed-lower-alpha {
  system: extends lower-alpha;
  prefix: "(";
  suffix: ") ";
}

@counter-style enclosed-lower-roman {
  system: extends lower-roman;
  prefix: "(";
  suffix: ") ";
}

@counter-style enclosed-upper-alpha {
  system: extends upper-alpha;
  prefix: "(";
  suffix: ") ";
}

And then, we just gotta replace each with its equivalent in our initial ol declarations:

ol {
  list-style-type: trimmed-decimal;

  ol {
    list-style-type: enclosed-lower-alpha;

    ol {
      list-style-type: enclosed-lower-roman;

      ol {
        list-style-type: enclosed-upper-alpha;
      }
    }
  }
}

It should work without CSS!

Remember, though, it’s a legal document, so what happens if the internet is weak enough so that only the HTML loads correctly, or if someone checks the page from an old browser that doesn’t support nesting or @counter-style?

Thinking only about the list, in most websites, it would be a mild annoyance where the markers go back to decimal, and you have to go by padding to know where each sublist starts. However, in a legal document, it can be a big deal. How big? I am no lawyer, so it beats me, but we still can make sure the list keeps its original numbering even without CSS.

For the task, we can use the HTML type attribute. It’s similar to CSS list-style-type but with its own limited uses. First, its use with ul elements is deprecated, while it can be used in ol elements to keep the lists correctly numbered even without CSS, like in legal or technical documents such as ours. It has the following values:

  • "1" for decimal numbers (default)
  • "a" for lowercase alphabetic
  • "A" for uppercase alphabetic
  • "i" for lowercase Roman numbers
  • "I" for uppercase Roman numbers

Inside our HTML list, we would assign the correct numbering for each ol level:

Depending on how long the document is, it may be more the hassle than the benefit, but it is still good to know. Although this kind of document doesn’t change constantly, so it wouldn’t hurt to add this extra safety net.

Welp, that was kinda too much for a list! But that’s something intrinsic to legal documents. Still, I think it’s the simplest way to achieve the initial reader’s goal. Let me know in the comments if you think this is overengineered or if there is an easier way.

More on lists!


A Reader’s Question on Nested Lists originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Geoff Graham
Fri, 16 May 2025 14:38:19 +0000


Some weekend reading on the heels of Global Accessibility Awareness Day (GAADM), which took place yesterday. The Email Markup Consortium (EMC) released its 2025 study on the accessibility in HTML emails, and the TL;DR is not totally dissimilar from what we heard from WebAIMโ€™s annual web report:

This is the third full year for this report and we are disappointed to see the same issues as we have in previous years. The top 10 issues havenโ€™t changed order since last year, apart from the addition of color contrast, which can be put down to a change in the testing and reporting.

The results come from an analysis of 443,585 emails collected from the past year. According to EMC, only 21 emails passed all accessibility checks โ€” and they were all written by the same author representing two different brands. And, further, that author represents one of the companies that not only sponsors the study, but develops the automated testing tool powering the analysis.

Automated testing is the key here. Thatโ€™s needed for a project looking at hundreds of thousands of emails, but it wonโ€™t surface everything, as noted:

Email that pass our checks may still have accessibility issues that we cannot pick up through automated testing. For example, we check if an alt attribute is present on an image, but we do not check if the text is suitable for that image in the context of that message.

The most common issues relate to internationalization, like leaving out the lang (96% of emails) and dir (98% of emails) attributes. But youโ€™ll be familiar with most of what rounds up the top 10, because it lines up with WebAIMโ€™s findings:

  • Links must have discernible text
  • Element has insufficient color contrast
  • Images must have alternate text
  • Link text should be descriptive
  • Links must be distinguishable without relying on color

I appreciate that the report sheds a light on what accessibility features are supported by specific email clients, such as Gmail. The report outlines a set of 20 HTML, CSS, and ARIA features they look for and found that only one email client (SFR Mail?) of the 44 evaluated supports all of the features. Apple Mail and Samsung Email are apparently close behind, but the other 41? Not so much.

AilSo, yeah. Email has a ways to go, like a small microcosm of the web itself.


HTML Email Accessibility Report 2025 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Geoff Graham
Thu, 15 May 2025 12:30:59 +0000


I was reflecting on what I learned about CSS Carousels recently. There’s a lot they can do right out of the box (and some things they don’t) once you define a scroll container and hide the overflow.

Hey, isn’t there another fairly new CSS feature that works with scroll regions? Oh yes, that’s Scroll-Driven Animations. Shouldn’t that mean we can trigger an animation while scrolling through the items in a CSS carousel?

Why yes, that’s exactly what it means. At least in Chrome at the time I’m playing with this:

It’s as straightforward as you might expect: define your keyframes and apply them on the carousel items:

@keyframes foo {
  from {
    height: 0;
  }
  to {
    height: 100%;
    font-size: calc(2vw + 1em);
  }
}

.carousel li {
  animation: foo linear both;
  animation-timeline: scroll(inline);
}

There are more clever ways to animate these things of course. But what’s interesting to me is that this demo now combines CSS Carousels with Scroll-Driven Animations. The only rub is that the demo also slaps CSS Scroll Snapping in there with smooth scrolling, which is effectively wiped out when applying the scroll animation.

I thought I might work around that with a view() timeline instead. That certainly makes for a smoother animation that is applied to each carousel item as they scroll into view, but no dice on smooth scrolling.


Scroll-Driven Animations Inside a CSS Carousel originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: John Rhea
Wed, 14 May 2025 14:01:42 +0000


I recently rebuilt my portfolio (johnrhea.com). After days and days of troubleshooting and fixing little problems on my local laptop, I uploaded my shiny new portfolio to the server โ€” and triumphantly watched it not work at allโ€ฆ

The browser parses and runs JavaScript, right? Maybe Chrome will handle something a little different from Firefox, but if the same code is on two different servers it should work the same in Chrome (or Firefox) no matter which server you look at, right? Right?

First, the dynamically generated stars wouldnโ€™t appear and when you tried to play the game mode, it was just blank. No really terrible website enemies appeared, nor could they shoot any bad experience missiles at you, at least, not in the game mode, but I guess my buggy website literally sent a bad experience missile at you. Over on the page showing my work, little cars were supposed to zoom down the street, but they didnโ€™t show up, either.

Let me tell you, there was no crying or tears of any kind. I was very strong and thrilled, just thrilled, to accept the challenge of figuring out what was going on. I frantically googled things like โ€œWhat could cause JavaScript to act differently on two servers?โ€, โ€œWhy would a server change how JavaScript works?โ€, and โ€œWhy does everyone think Iโ€™m crying when Iโ€™m clearly not?โ€ But to no avail.

There were some errors in the console, but not ones that made sense. I had an SVG element that weโ€™ll call car (because thatโ€™s what I named it). I created it in vanilla JavaScript, added it to the page, and zoomed it down the gray strip approximating a street. (Itโ€™s a space theme where you can explore planets. Itโ€™s really cool. I swear.) I was setting transforms on car using car.style.transform and it was erroring out. car.style was undefined.

Uncaught TypeError: Cannot set properties of undefined.

I went back to my code on my laptop. Executes flawlessly. No errors.

To get past the initial error, I switched it from car.style to using setAttribute e.g. car.setAttribute('style', 'transform: translate(100px, 200px)');. This just got me to the next error. car was erroring out on some data-* attributes I was using to hold information about the car, e.g. car.dataset.xspeed would also come back undefined when I tried to access them. This latter technology has been supported in SVG elements since 2015, yet it was not working on the server, and worked fine locally. What the Hoobastank could be happening? (Yes, Iโ€™m referencing the 1990s band and, no, they have nothing to do with the issue. I just like sayingโ€ฆ errrโ€ฆ writingโ€ฆ their name.)

With search engines not being much help (mostly because the problem isnโ€™t supposed to exist), I contacted my host thinking maybe some kind of server configuration was the issue. The very polite tech tried to help, checking for server errors and other simple misconfigurations, but there were no issues he could find. After reluctantly serving as my coding therapist and listening to my (tearless) bemoaning of ever starting a career in web development, he basically said they support JavaScript, but canโ€™t really go into custom code, so best of luck. Well, thanks for nothing, person whom I will call Truckson! (Thatโ€™s not his real name, but I thought โ€œCarsonโ€ was too on the nose.)

Next, and still without tears, I tried to explain my troubles to ChatGPT with the initial prompt: โ€œWhy would JavaScript on two different web servers act differently?โ€ It was actually kind of helpful with a bunch of answers that turned out to be very wrong.

  • Maybe there was an inline SVG vs SVG in an img issue? That wasnโ€™t it.
  • Could the browser be interpreting the page as plain text instead of HTML through some misconfiguration? Nope, it was pulling down HTML, and the headers were correct.
  • Maybe the browser is in quirks mode? It wasnโ€™t.
  • Could the SVG element be created incorrectly? You canโ€™t create an SVG element in HTML using document.createElement('svg') because SVG actually has a different namespace. Instead, you have to use document.createElementNS("http://www.w3.org/2000/svg", 'svg'); because SVG and HTML use similar, but very different, standards. Nope, Iโ€™d used the createElementNS function and the correct namespace.

Sidenote: At several points during the chat session, ChatGPT started replies with, โ€œAh, now we’re getting spicy 🔥โ€ as well as, โ€œAh, this is a juicy one. 🍇โ€ (emojis included). It also used the word โ€œbulletproofโ€ a few times in what felt like a tech-bro kind of way. Plus there was a โ€œBOOM. 💥 Thatโ€™s the smoking gun right thereโ€, as well as an โ€œAhhh okay, sounds like there’s still a small gremlin in the works.โ€ I canโ€™t decide whether I find this awesome, annoying, horrible, or scary. Maybe all four?

Next, desperate, I gave our current/future robot overlord some of my code to give it context and show it that none of these were the issue. It still harped on the misconfiguration and kept having me output things to check if the car element was an SVG element. Again, locally it was an SVG element, but on the server it came back that it wasnโ€™t.

  • Maybe using innerHTML to add some SVG elements to the car element garbled the car element into not being an SVG element? ChatGPT volunteered to rewrite a portion of code to fix this. I put the new code into my system. It worked locally! Then I uploaded it to the server andโ€ฆ no dice. Same error was still happening.

I wept openly. I meanโ€ฆ I swallowed my emotions in a totally healthy and very manly way. And thatโ€™s the end of the article, no redemption, no solution, no answer. Just a broken website and the loud sobs of a man who doesnโ€™t cryโ€ฆ everโ€ฆ

โ€ฆYou still here?

Okay, youโ€™re right. You know I wouldnโ€™t leave you hanging like that. After the non-existent sob session, I complained to ChatGPT, it again gave me some console logs including having the car element print out its namespace and thatโ€™s when the answer came to me. You see the namespace for an SVG is this:

http://www.w3.org/2000/svg

What it actually printed was this:

https://www.w3.org/2000/svg

One letter. Thatโ€™s the difference.

Normally you want everything to be secure, but thatโ€™s not really how namespaces work. And while the differences between these two strings is minimal, I might as well have written document.createElementNS("Gimme-them-SVGzers", "svg");. Hey, W3C, can I be on the namespace committee?

But why was it different? Youโ€™d be really mad if you read this far and it was just a typo in my code. Right?

Youโ€™ve invested some time into this article, and I already did the fake-out of having no answer. So, having a code typo would probably lead to riots in the streets and hoards of bad reviews.

Donโ€™t worry. The namespace was correct in my code, so where was that errant โ€œsโ€ coming from?

I remembered turning on a feature in my hostโ€™s optimization plugin: automatically fix insecure pages. It goes through and changes insecure links to secure ones. In 99% of cases, itโ€™s the right choice. But apparently it also changes namespace URLs in JavaScript code.

I turned that feature off and suddenly I was traversing the galaxy, exploring planets with cars zooming down gray pseudo-elements, and firing lasers at really terrible websites instead of having a really terrible website. There were no tears (joyful or otherwise) nor were there celebratory and wildly embarrassing dance moves that followed.

Have a similar crazy troubleshooting issue? Have you solved an impossible problem? Let me know in the comments.


Thisย Isnโ€™t Supposed to Happen: Troubleshooting the Impossible originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Chris Coyier
Mon, 12 May 2025 17:00:57 +0000


Sometimes we gotta get into the unglamorous parts of CSS. I mean *I* think they are pretty glamorous: new syntax, new ideas, new code doing foundational and important things. I just mean things that don’t demo terribly well. Nothing is flying across the screen, anyway.

  • The Future of CSS: Constructย <custom-ident>ย andย <dashed-ident>ย values withย ident() by Bramus Van Damme โ€” When you go anchor-name: --name; the --name part is a custom property, right? No. It is a “custom ident”. It doesn’t have a value, it’s just a name. Things get more interesting with ident() as a function, which can help us craft them from other attributes and custom properties, making for much less repetitive code in some situations.
  • Beating !important user agent styles (sort of) by Noah Liebman โ€” Using !important is a pretty hardcore way for a rule to apply, made even more hardcore when used by a low level stylesheet, of which user agent styles are the lowest. So is it even possible to beat a style set that way? Click to find out.
  • Hereโ€™s Why Your Anchor Positioning Isnโ€™t Working by James Stuckey Weber โ€” There is a whole host of reasons why including DOM positioning and order. If you ask Una she’ll say it’s probably the inset property.
  • Faux Containers in CSS Grids by Tyler Sticka โ€” Elements that stick out of their “container” is a visually compelling look. A classic way to do it is with negative margins and absolute positioning and the like. But those things are a smidge “dangerous” in that they can cause overlaps and unexpected behavior due to being out of regular flow. I like Tyler’s idea here of keeping it all contained to a grid and just making it look like it’s breaking out.
  • Introducing @bramus/style-observer, a MutationObserver for CSS by Bramus Van Damme โ€” A regular MutationObserver watches the DOM for changes. But not style changes. Bramus has created a version of it that does, thanks to a very newfangled CSS property that helps it work efficiently. I’m not overflowing with use case ideas, but I have a feeling that when you need it, you need it.
  • Using the upcoming CSS when/else rules by Christiana Uloma โ€” There is a working draft spec for @when/@else so while these aren’t real right now, maybe they will be? The if() function seems more real and maybe that is enough here? The if() function would just be a value though not a whole block of stuff, so maybe we’ll get both.
by: Ryan Trimble
Mon, 12 May 2025 12:42:10 +0000


Friends, I’ve been on the hunt for a decent content management system for static sites forโ€ฆ well, about as long as we’ve all been calling them “static sites,” honestly.

I know, I know: there are a ton of content management system options available, and while I’ve tested several, none have really been the one, y’know? Weird pricing models, difficult customization, some even end up becoming a whole ‘nother thing to manage.

Also, I really enjoy building with site generators such as Astro or Eleventy, but pitching Markdown as the means of managing content is less-than-ideal for many “non-techie” folks.

A few expectations for content management systems might include:

  • Easy to use: The most important feature, why you might opt to use a content management system in the first place.
  • Minimal Requirements: Look, I’m just trying to update some HTML, I don’t want to think too much about database tables.
  • Collaboration: CMS tools work best when multiple contributors work together, contributors who probably don’t know Markdown or what GitHub is.
  • Customizable: No website is the same, so we’ll need to be able to make custom fields for different types of content.

Not a terribly long list of demands, I’d say; fairly reasonable, even. That’s why I was happy to discover Pages CMS.

According to its own home page, Pages CMS is the “The No-Hassle CMS for Static Site Generators,” and I’ll to attest to that. Pages CMS has largely been developed by a single developer, Ronan Berder, but is open source, and accepting pull requests over on GitHub.

Taking a lot of the “good parts” found in other CMS tools, and a single configuration file, Pages CMS combines things into a sleek user interface.

Pages CMS includes lots of options for customization, you can upload media, make editable files, and create entire collections of content. Also, content can have all sorts of different fields, check the docs for the full list of supported types, as well as completely custom fields.

There isn’t really a “back end” to worry about, as content is stored as flat files inside your git repository. Pages CMS provides folks the ability to manage the content within the repo, without needing to actually know how to use Git, and I think that’s neat.

User Authentication works two ways: contributors can log in using GitHub accounts, or contributors can be invited by email, where they’ll receive a password-less, “magic-link,” login URL. This is nice, as GitHub accounts are less common outside of the dev world, shocking, I know.

Oh, and Pages CMS has a very cheap barrier for entry, as it’s free to use.

Pages CMS and Astro content collections

I’ve created a repository on GitHub with Astro and Pages CMS using Astro’s default blog starter, and made it available publicly, so feel free to clone and follow along.

I’ve been a fan of Astro for a while, and Pages CMS works well alongside Astro’s content collection feature. Content collections make globs of data easily available throughout Astro, so you can hydrate content inside Astro pages. These globs of data can be from different sources, such as third-party APIs, but commonly as directories of Markdown files. Guess what Pages CMS is really good at? Managing directories of Markdown files!

Content collections are set up by a collections configuration file. Check out the src/content.config.ts file in the project, here we are defining a content collection named blog:

import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
// Load Markdown in the `src/content/blog/` directory.
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
  // Type-check frontmatter using a schema
  schema: z.object({
    title: z.string(),
   description: z.string(),
    // Transform string to Date object
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    heroImage: z.string().optional(),
  }),
});

export const collections = { blog };

The blog content collection checks the /src/content/blog directory for files matching the **/*.md file type, the Markdown file format. The schema property is optional, however, Astro provides helpful type-checking functionality with Zod, ensuring data saved by Pages CMS works as expected in your Astro site.

Pages CMS Configuration

Alright, now that Astro knows where to look for blog content, let’s take a look at the Pages CMS configuration file, .pages.config.yml:

content:
  - name: blog
    label: Blog
    path: src/content/blog
    filename: '{year}-{month}-{day}-{fields.title}.md'
    type: collection
    view:
      fields: [heroImage, title, pubDate]
    fields:
      - name: title
        label: Title
        type: string

      - name: description
        label: Description
        type: text

      - name: pubDate
        label: Publication Date
        type: date
        options:
          format: MM/dd/yyyy

      - name: updatedDate
        label: Last Updated Date
        type: date
        options:
          format: MM/dd/yyyy

      - name: heroImage
        label: Hero Image
        type: image

      - name: body
        label: Body
        type: rich-text

  - name: site-settings
    label: Site Settings
    path: src/config/site.json
    type: file
    fields:
      - name: title
        label: Website title
        type: string

      - name: description
        label: Website description
        type: string
        description: Will be used for any page with no description.

      - name: url
        label: Website URL
        type: string
        pattern: ^(https?:\/\/)?(www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\/[^\s]*)?$

      - name: cover
        label: Preview image
        type: image
        description: Image used in the social preview on social networks (e.g. Facebook, Twitter...)

media:
  input: public/media
  output: /media

There is a lot going on in there, but inside the content section, let’s zoom in on the blog object.

- name: blog
  label: Blog
  path: src/content/blog
  filename: '{year}-{month}-{day}-{fields.title}.md'
  type: collection
  view:
    fields: [heroImage, title, pubDate]
  fields:
    - name: title
      label: Title
      type: string

    - name: description
      label: Description
      type: text

    - name: pubDate
      label: Publication Date
      type: date
      options:
        format: MM/dd/yyyy

    - name: updatedDate
      label: Last Updated Date
      type: date
      options:
        format: MM/dd/yyyy

    - name: heroImage
      label: Hero Image
      type: image

    - name: body
      label: Body
      type: rich-text

We can point Pages CMS to the directory we want to save Markdown files using the path property, matching it up to the /src/content/blog/ location Astro looks for content.

path: src/content/blog

For the filename we can provide a pattern template to use when Pages CMS saves the file to the content collection directory. In this case, it’s using the file date’s year, month, and day, as well as the blog item’s title, by using fields.title to reference the title field. The filename can be customized in many different ways, to fit your scenario.

filename: '{year}-{month}-{day}-{fields.title}.md'

The type property tells Pages CMS that this is a collection of files, rather than a single editable file (we’ll get to that in a moment).

type: collection

In our Astro content collection configuration, we define our blog collection with the expectation that the files will contain a few bits of meta data such as: title, description, pubDate, and a few more properties.

We can mirror those requirements in our Pages CMS blog collection as fields. Each field can be customized for the type of data you’re looking to collect. Here, I’ve matched these fields up with the default Markdown frontmatter found in the Astro blog starter.

fields:
  - name: title
    label: Title
    type: string

  - name: description
    label: Description
    type: text

  - name: pubDate
    label: Publication Date
    type: date
    options:
      format: MM/dd/yyyy

  - name: updatedDate
    label: Last Updated Date
    type: date
    options:
      format: MM/dd/yyyy

  - name: heroImage
    label: Hero Image
    type: image

  - name: body
    label: Body
    type: rich-text

Now, every time we create a new blog item in Pages CMS, we’ll be able to fill out each of these fields, matching the expected schema for Astro.

Aside from collections of content, Pages CMS also lets you manage editable files, which is useful for a variety of things: site wide variables, feature flags, or even editable navigations.

Take a look at the site-settings object, here we are setting the type as file, and the path includes the filename site.json.

- name: site-settings
  label: Site Settings
  path: src/config/site.json
  type: file
  fields:
    - name: title
      label: Website title
      type: string

    - name: description
      label: Website description
      type: string
      description: Will be used for any page with no description.

    - name: url
      label: Website URL
      type: string
      pattern: ^(https?:\/\/)?(www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\/[^\s]*)?$

    - name: cover
      label: Preview image
      type: image
      description: Image used in the social preview on social networks (e.g. Facebook, Twitter...)

The fields I’ve included are common site-wide settings, such as the site’s title, description, url, and cover image.

Speaking of images, we can tell Pages CMS where to store media such as images and video.

media:
  input: public/media
  output: /media

The input property explains where to store the files, in the /public/media directory within our project.

The output property is a helpful little feature that conveniently replaces the file path, specifically for tools that might require specific configuration. For example, Astro uses Vite under the hood, and Vite already knows about the public directory and complains if it’s included within file paths. Instead, we can set the output property so Pages CMS will only point image path locations starting at the inner /media directory instead.

To see what I mean, check out the test post in the src/content/blog/ folder:

---
title: 'Test Post'
description: 'Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.'
pubDate: 05/03/2025
heroImage: '/media/blog-placeholder-1.jpg'
---

The heroImage now property properly points to /media/... instead of /public/media/....

As far as configurations are concerned, Pages CMS can be as simple or as complex as necessary. You can add as many collections or editable files as needed, as well as customize the fields for each type of content. This gives you a lot of flexibility to create sites!

Connecting to Pages CMS

Now that we have our Astro site set up, and a .pages.config.yml file, we can connect our site to the Pages CMS online app. As the developer who controls the repository, browse to https://app.pagescms.org/ and sign in using your GitHub account.

You should be presented with some questions about permissions, you may need to choose between giving access to all repositories or specific ones. Personally, I chose to only give access to a single repository, which in this case is my astro-pages-cms-template repo.

granting permission to a single repository, called mrtrimble/astro-pages-cms-template

After providing access to the repo, head on back to the Pages CMS application, where you’ll see your project listed under the “Open a Project” headline.

text that reads "Open a project" with a list of projects, the astro pages cms template is visible at the top

Clicking the open link will take you into the website’s dashboard, where we’ll be able to make updates to our site.

Creating content

Taking a look at our site’s dashboard, we’ll see a navigation on the left side, with some familiar things.

Blog dashboard page, displaying the blog entries, there is only one at the moment, called Test Post. At the top, a button labeled "Add an entry"
  • Blog is the collection we set up inside the .pages.config.yml file, this will be where we we can add new entries to the blog.
  • Site Settings is the editable file we are using to make changes to site-wide variables.
  • Media is where our images and other content will live.
  • Settings is a spot where we’ll be able to edit our .pages.config.yml file directly.
  • Collaborators allows us to invite other folks to contribute content to the site.

We can create a new blog post by clicking the Add Entry button in the top right

create a new entry page, with fields for title, description publication date, last updated date, hero image, and body

Here we can fill out all the fields for our blog content, then hit the Save button.

After saving, Pages CMS will create the Markdown file, store the file in the proper directory, and automatically commit the changes to our repository. This is how Pages CMS helps us manage our content without needing to use git directly.

Automatically deploying

The only thing left to do is set up automated deployments through the service provider of your choice. Astro has integrations with providers like Netlify, Cloudflare Pages, and Vercel, but can be hosted anywhere you can run node applications.

Astro is typically very fast to build (thanks to Vite), so while site updates won’t be instant, they will still be fairly quick to deploy. If your site is set up to use Astro’s server-side rendering capabilities, rather than a completely static site, the changes might be much faster to deploy.

Wrapping up

Using a template as reference, we checked out how Astro content collections work alongside Pages CMS. We also learned how to connect our project repository to the Pages CMS app, and how to make content updates through the dashboard. Finally, if you are able, don’t forget to set up an automated deployment, so content publishes quickly.


Using Pages CMS for Static Site Content Management originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: John Rhea
Thu, 08 May 2025 12:33:29 +0000


I recently updated my portfolio at johnrhea.com. (If youโ€™re looking to add a CSS or front-end engineer with storytelling and animation skills to your team, Iโ€™m your guy.) I liked the look of a series of planets Iโ€™d created for another personal project and decided to reuse them on my new site. Part of that was also reusing an animation Iโ€™d built circa 2019, where a moon orbited around the planet.

Initially, I just plopped the animations into the new site, only changing the units (em units to viewport units using some complicated math that I was very, very proud of) so that they would scale properly because Iโ€™mโ€ฆ efficient with my time. However, on mobile, the planet would move up a few pixels and down a few pixels as the moons orbited around it. I suspected the plopped-in animation was the culprit (it wasnโ€™t, but at least I got some optimized animation and an article out of the deal).

Hereโ€™s the original animation:

My initial animation for the moon ran for 60 seconds. Iโ€™m folding it inside a disclosure widget because, at 141 lines, itโ€™s stupid long (and, as weโ€™ll see, emphasis on the stupid). Here it is in all its โ€œgloryโ€:

Open code
#moon1 {
  animation: moon-one 60s infinite;
}

@keyframes moon-one {
  0% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  5% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  9.9% {
    z-index: 2;
  }
  10% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  15% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  19.9% {
    z-index: -1;
  }
  20% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  25% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  29.9% {
    z-index: 2;
  }
  30% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  35% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  39.9% {
    z-index: -1;
  }
  40% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  45% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  55% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  59.9% {
    z-index: -1;
  }
  60% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  65% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  69.9% {
    z-index: 2;
  }
  70% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  75% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  79.9% {
    z-index: -1;
  }
  80% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  85% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  89.9% {
    z-index: 2;
  }
  90% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  95% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  99.9% {
    z-index: -1;
  }
  100% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
}

If you look at the keyframes in that code, youโ€™ll notice that the 0% to 20% keyframes are exactly the same as 20% to 40% and so on up through 100%. Why I decided to repeat the keyframes five times infinitely instead of just repeating one set infinitely is a decision lost to antiquity, like six years ago in web time. We can also drop the duration to 12 seconds (one-fifth of sixty) if we were doing our due diligence.

I could thus delete everything from 20% on, instantly dropping the code down to 36 lines. And yes, I realize gains like this are unlikely to be possible on most sites, but this is the first step for optimizing things.

#moon1 {
  animation: moon-one 12s infinite;
}

@keyframes moon-one {
  0% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  5% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  9.9% {
    z-index: 2;
  }
  10% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  15% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  19.9% {
    z-index: -1;
  }
  20% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
}

Now that weโ€™ve gotten rid of 80% of the overwhelming bits, we can see that there are five main keyframes and two additional ones that set the z-index close to the middle and end of the animation (these prevent the moon from dropping behind the planet or popping out from behind the planet too early). We can change these five points from 0%, 5%, 10%, 15%, and 20% to 0%, 25%, 50%, 75%, and 100% (and since the 0% and the former 20% are the same, we can remove that one, too). Also, since the 10% keyframe above is switching to 50%, the 9.9% keyframe can move to 49.9%, and the 19.9% keyframe can switch to 99.9%, giving us this:

#moon1 {
  animation: moon-one 12s infinite;
}

@keyframes moon-one {
  0%, 100% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  25% {
    transform: translate(-3.51217391vw, 3.50608696vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    transform: translate(-5.01043478vw, 6.511304348vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  75% {
    transform: translate(1.003478261vw, 2.50608696vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  99.9% {
    z-index: -1;
  }
}

Though I was very proud of myself for my math wrangling, numbers like -3.51217391vw are really, really unnecessary. If a screen was one thousand pixels wide, -3.51217391vw would be 35.1217391 pixels. No one ever needs to go down to the precision of a ten-millionth of a pixel. So, let’s round everything to the tenth place (and if itโ€™s a 0, weโ€™ll just drop it). We can also skip z-index in the 75% and 25% keyframes since it doesnโ€™t change.

Hereโ€™s where that gets us in the code:

#moon1 {
  animation: moon-one 12s infinite;
}

@keyframes moon-one {
  0%, 100% {
    transform: translate(0, 0) scale(1);
    z-index: 2;
    animation-timing-function: ease-in;
  }
  25% {
    transform: translate(-3.5vw, 3.5vw) scale(1.5);
    z-index: 2;
    animation-timing-function: ease-out;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    transform: translate(-5vw, 6.5vw) scale(1);
    z-index: -1;
    animation-timing-function: ease-in;
  }
  75% {
    transform: translate(1vw, 2.5vw) scale(0.25);
    z-index: -1;
    animation-timing-function: ease-out;
  }
  99.9% {
    z-index: -1;
  }
}

After all our changes, the animation still looks pretty close to what it was before, only way less code:

One of the things I donโ€™t like about this animation is that the moon kind of turns at its zenith when it crosses the planet. It would be much better if it traveled in a straight line from the upper right to the lower left. However, we also need it to get a little larger, as if the moon is coming closer to us in its orbit. Because both translation and scaling were done in the transform property, I canโ€™t translate and scale the moon independently.

If we skip either one in the transform property, it resets the one we skipped, so Iโ€™m forced to guess where the mid-point should be so that I can set the scale I need. One way Iโ€™ve solved this in the past is to add a wrapping element, then apply scale to one element and translate to the other. However, now that we have individual scale and translate properties, a better way is to separate them from the transform property and use them as separate properties. Separating out the translation and scaling shouldn’t change anything, unless the original order they were declared on the transform property was different than the order of the singular properties.

#moon1 {
  animation: moon-one 12s infinite;
}

@keyframes moon-one {
  0%, 100% {
    translate: 0 0;
    scale: 1;
    z-index: 2;
    animation-timing-function: ease-in;
  }
  25% {
    translate: -3.5vw 3.5vw;
    z-index: 2;
    animation-timing-function: ease-out;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    translate: -5vw 6.5vw;
    scale: 1;
    z-index: -1;
    animation-timing-function: ease-in;
  }
  75% {
    translate: 1vw 2.5vw;
    scale: 0.25;
    animation-timing-function: ease-out;
  }
  99.9% {
    z-index: -1;
  }
}

Now that we can separate the scale and translate properties and use them independently, we can drop the translate property in the 25% and 75% keyframes because we donโ€™t want them placed precisely in that keyframe. We want the browserโ€™s interpolation to take care of that for us so that it translates smoothly while scaling.

#moon1 {
  animation: moon-one 12s infinite;
}

@keyframes moon-one {
  0%, 100% {
    translate: 0 0;
    scale: 1;
    z-index: 2;
    animation-timing-function: ease-in;
  }
  25% {
    scale: 1.5;
    animation-timing-function: ease-out;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    translate: -5vw 6.5vw;
    scale: 1;
    z-index: -1;
    animation-timing-function: ease-in;
  }
  75% {
    scale: 0.25;
    animation-timing-function: ease-out;
  }
  99.9% {
    z-index: -1;
  }
}

Lastly, those different timing functions donโ€™t make a lot of sense anymore because weโ€™ve got the browser working for us, and if we use an ease-in-out timing function on everything, then it should do exactly what we want.

#moon1 {
  animation: moon-one 12s infinite ease-in-out;
}

@keyframes moon-one {
  0%, 100% {
    translate: 0 0;
    scale: 1;
    z-index: 2;
  }
  25% {
    scale: 1.5;
  }
  49.9% {
    z-index: 2;
  }
  50% {
    translate: -5vw 6.5vw;
    scale: 1;
    z-index: -1;
  }
  75% {
    scale: 0.25;
  }
  99.9% {
    z-index: -1;
  }
}

And there you go: 141 lines down to 28, and I think the animation looks even better than before. It will certainly be easier to maintain, thatโ€™s for sure.

But what do you think? Was there an optimization step I missed? Let me know in the comments.


Orbital Mechanics (or How I Optimized a CSS Keyframes Animation) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

by: Sunkanmi Fafowora
Wed, 07 May 2025 12:25:19 +0000


Okay, nobody is an exaggeration, but have you seen the stats for hwb()? They show a steep decline, and after working a lot on color in the CSS-Tricks almanac, Iโ€™ve just been wondering why that is.

Chrome Platform Status for hwb(), showing a steep decline in usage from 0.4 percent of all page loads in 2024 to less than .2 perfect in 2025.

hwb() is a color function in the sRGB color space, which is the same color space used by rgb()hsl() and the older hexadecimal color format (e.g. #f8a100). hwb() is supposed to be more intuitive and easier to work with than hsl(). I kinda get why it’s considered “easier” since you specify how much black or white you want to add to a given color. But, how is hwb() more intuitive than hsl()?

hwb() accepts three values, and similar to hsl(), the first value specifies the color’s hue (between 0deg360deg), while the second and third values add whiteness (0 – 100) and blackness (0 – 100) to the mix, respectively.

According to Google, the term โ€œintuitiveโ€ means โ€œwhat one feels to be true even without conscious reasoning; instinctive.โ€ As such, it does truly seem that hwb() is more intuitive than hsl(), but itโ€™s only a slight notable difference that makes that true.

Letโ€™s consider an example with a color. We’ll declare light orange in both hsl() and hwb():

/* light orange in hsl */
.element-1 {
  color: hsl(30deg 100% 75%);
}

/* light orange in hwb() */
.element-2 {
  color: hwb(30deg 50% 0%);
}

These two functions produce the exact same color, but while hwb() handles ligthness with two arguments, hsl() does it with just one, leaving one argument for the saturation. By comparison, hwb() provides no clear intuitive way to set just the saturation. I’d argue that makes the hwb() function less intuitive than hsl().

I think another reason that hsl() is generally more intuitive than hwb() is that HSL as a color model was created in the 1970s while HWB as a color model was created in 1996. We’ve had much more time to get acquainted with hsl() than we have hwb(). hsl() was implemented by browsers as far back as 2008, Safari being the first and other browsers following suit. Meanwhile, hwb() gained support as recently as 2021! Thatโ€™s more than a 10-year gap between functions when it comes to using them and being familiar with them.

There’s also the fact that other color functions that are used to represent colors in other color spaces โ€” such as lab()lch()oklab(), and oklch() โ€” offer more advantages, such as access to more colors in the color gamut and perceptual uniformity. So, maybe being intuitive is coming at the expense of having a more robust feature set, which could explain why you might go with a less intuitive function that doesn’t use sRGB.

Look, I can get around the idea of controlling how white or black you want a color to look based on personal preferences, and for designers, itโ€™s maybe easier to mix colors that way. But I honestly would not opt for this as my go-to color function in the sRGB color space because hsl() does something similar using the same hue, but with saturation and lightness as the parameters which is far more intuitive than what hwb() offers.

I see our web friend, Stefan Judis, preferring hsl() over hwb() in his article on hwb().

Lea Verou even brought up the idea of removing hwb() from the spec in 2022, but a decision was made to leave it as it was since browsers were already implementing the function. And although,I was initially pained by the idea of keeping hwb() around, I also quite understand the feeling of working on something, and then seeing it thrown in the bin. Once we’ve introduced something, it’s always tough to walk it back, especially when it comes to maintaining backwards compatibility, which is a core tenet of the web.

I would like to say something though: lab()lch()oklab()oklch() are already here and are better color functions than hwb(). I, for one, would encourage using them over hwb() because they support so many more colors that are simply missing from the hsl() and hwb() functions.

Iโ€™ve been exploring colors for quite some time now, so any input would be extremely helpful. What color functions are you using in your everyday website or web application, and why?

More on color


Why is Nobody Using the hwb() Color Function? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Important Information

Terms of Use Privacy Policy Guidelines We have placed cookies on your device to help make this website better. You can adjust your cookie settings, otherwise we'll assume you're okay to continue.