Category: CSS Tricks

  • Distinguishing “Components” and “Utilities” in Tailwind

    Here’s a really quick tip. You can think of Tailwind utilities as components — because you can literally make a card “component” out of Tailwind utilities.

    @utility card {
      border: 1px solid black;
      padding: 1rlh;
    }
    <div class="card"> ... </div>
    A plain white rectangular box with a black border.

    This blurs the line between “Components” and “Utilities” so we need to better define those terms.

    The Great Divide — and The Great Unification

    CSS developers often define Components and Utilities like this:

    1. Component = A group of styles
    2. Utility = A single rule

    This collective thinking has emerged from the terminologies we have gathered over many years. Unfortunately, they’re not really the right terminologies.

    So, let’s take a step back and consider the actual meaning behind these words.

    Component means: A thing that’s a part of a larger whole.

    1640s, "constituent part or element" (earlier "one of a group of persons," 1560s), from Latin componentem (nominative componens), present participle of componere to put together, to collect a whole from several parts, from com with, together
(see com-) + ponere to place (see position (n.)). Related: Componential. Meaning mechanical part of a bicycle, automobile, etc. is from 1896. As an adjective, constituent, entering into the composition of, from 1660s.

    Utility means: It’s useful.

    late 14th century., utilite, 'fact or character of being useful,' from Old French utilite 'usefulness' (13th century, Modern French utilite), earlier utilitet (12th century.), from Latin utilitatem (nominative utilitas) 'usefulness, serviceableness, profit,' from utilis 'usable,' from uti 'make use of, profit by, take advantage of.'

    So…

    • Utilities are Components because they’re still part of a larger whole.
    • Components are Utilities because they’re useful.

    The division between Components and Utilities is really more of a marketing effort designed to sell those utility frameworks — nothing more than that.

    It. Really. Doesn’t. Matter.

    The meaningful divide?

    Perhaps the only meaningful divide between Components and Utilities (in the way they’re commonly defined so far) is that we often want to overwrite component styles.

    It kinda maps this way:

    • Components: Groups of styles
    • Utilities: Styles used to overwrite component styles.

    Personally, I think that’s a very narrow way to define something that actually means “useful.”

    Just overwrite the dang style

    Tailwind provides us with an incredible feature that allows us to overwrite component styles. To use this feature, you would have to:

    • Write your component styles in a components layer.
    • Overwrite the styles via a Tailwind utility.
    @layer components {
      .card {
        border: 1px solid black;
        padding: 1rlh;
      }
    }
    <div class="card border-blue-500"> ... </div>
    A simple rectangular box with a blue border.

    But this is a tedious way of doing things. Imagine writing @layer components in all of your component files. There are two problems with that:

    1. You lose the ability to use Tailwind utilities as components
    2. You gotta litter your files with many @layer component declarations — which is one extra indentation and makes the whole CSS a little more difficult to read.

    There’s a better way of doing this — we can switch up the way we use CSS layers by writing utilities as components.

    @utility card {
      padding: 1rlh; 
      border: 1px solid black;
    }

    Then, we can overwrite styles with another utility using Tailwind’s !important modifier directly in the HTML:

    <div class="card !border-blue-500"> ... </div>

    I put together an example over at the Tailwind Playground.

    Unorthodox Tailwind

    This article comes straight from my course, Unorthodox Tailwind, where you’ll learn to use CSS and Tailwind in a synergistic way. If you liked this, there’s a lot more inside: practical ways to think about and use Tailwind + CSS that you won’t find in tutorials or docs.


    Distinguishing “Components” and “Utilities” in Tailwind originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Spiral Scrollytelling in CSS With sibling-index()

    Confession time: I’ve read about the performance benefits of scroll-timeline(), but when I see an impressive JavaScript scrollytelling site like this one, it makes me question if the performance of old-school, main-thread scrollytelling is all that bad. The other shoe drops when the creators of that site admit they “ran into real limits,” and “mobile technically works, but it loses parallax and chops compositions,” to the extent that they “chose to gate phones to protect the first impression.” Put another way: they couldn’t get it working on mobile, and it sounds like JavaScript performance may have been one of the culprits.

    The creator of another of my favorite scrolling experiments — which also uses JavaScript and also works best on desktop — called out that his text vortex section “would look better if it were applied for each character rather than each word, but that’s incredibly difficult to pull off using this same technique without incurring an astronomical performance impact.”

    Challenge accepted.

    He may have inadvertently created a realistic benchmark test for smoothly animating hundreds of divs based on scrolling.

    That’s our cue to see if we can make a lookalike effect using modern CSS features to smoothly spiral every character in a string of text as the user scrolls down. To give the original text vortex some CSS sibling rivalry, let’s give the new sibling-index() function a whirl, although it is still waiting on Firefox support at the time of writing. Therefore, as a fallback for the CodePen below, you can also watch the video of the screen recording.

    Confession #2: This uses some script

    The only JavaScript is to split the text into a <div> for each character, but the animation is pure CSS. I could have hardcoded all the markup instead, but that would make the HTML annoying to read and maintain. The following script makes it easy for you to experiment with the pen by tweaking the text content.

    const el = document.querySelector(".vortex");
    el.innerHTML = el.innerHTML.replaceAll(/\s/g, '⠀');
    new SplitText(".title", { type: "chars", charsClass: "char" });

    The SplitText plugin referenced here is from the freely available GSAP library. The plugin is designed to be usable standalone outside GSAP, which is what’s happening here. It is nice and simple to use, and it even populates aria-label so screen readers can see our text, regardless of the way we tokenize it. The one complication was that I wanted every space character to be in its own <div> that I could position. The simplest way I could find was to replace the spaces with a special space character, which SplitText will put into its own <div>. If anyone knows a better way, I’d love to hear about it in the comments.

    Now that we have each character living in its own <div>, we can implement the CSS to handle the spiral animation.

    .vortex {
      position: fixed;
      left: 50%;
      height: 100vh;
      animation-name: vortex;
      animation-duration: 20s;
      animation-fill-mode: forwards;
      animation-timeline: scroll();
    
      .char {
        --radius: calc(10vh - (7vh/sibling-count() * sibling-index()));
        --rotation: calc((360deg * 3/sibling-count()) * sibling-index());
    
        position: absolute !important;
        top: 50%;
        left: 50%;
        transform: rotate(var(--rotation))
          translateY(calc(-2.9 * var(--radius)))
          scale(calc(.4 - (.25/(sibling-count()) * sibling-index())));
        animation-name: fade-in;
        animation-ranger-start: calc(90%/var(sibling-count()) * var(--sibling-index()));
        animation-fill-mode: forwards;
        animation-timeline: scroll();
      }
    }

    Spiral and fade the elements using sibling-index() and sibling-count()

    We use the sibling-count and sibling-index functions together to calculate a gradual decrease for several properties of the characters when the sibling-index increases, using a formula like this:

    propertyValue = startValue - ((reductionValue/totalCharacters) * characterIndex)

    The first character starts near the maximum value. Each subsequent character subtracts a slightly larger fraction, so properties gradually dwindle to a chosen target value as the characters spiral inward. This technique is used to drive scale, rotation, and distance from the center.

    If the goal had been to arrange the characters in a circle instead of a spiral, I would have used CSS trigonometric functions as demonstrated here. However, the spiral seemed simpler to calculate without trig. Evidently, the original JavaScript version that inspired my CSS text spiral didn’t use trig either. The scroll animation is relatively simple as it’s just scaling and rotating the entire parent element to give the illusion that the viewer is being sucked into the vortex.

    The only animation applied to individual characters is fade-in which is delayed increasingly for each character in the string, using another variation on the usage of the ratio of sibling-index() to sibling-count(). In this case, we increment animation-range-start to stagger the delay before characters fade in as the user scrolls. It’s reminiscent of the infamous scroll-to-fade effect, and it makes me realize how often we reach for JavaScript just because it allows us to base styling on element indexes. Therefore, many JavaScript effects can likely be replaced with CSS once sibling-index() goes Baseline. Please do let me know in the comments if you can think of other examples of JavaScript effects we could recreate in CSS using sibling-index().


    Spiral Scrollytelling in CSS With sibling-index() originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Interop 2026

    Interop 2026 is formally a thing. So, you know all of those wild, new CSS features we’re always poking at but always putting under a “lack of browser support” caveat? The Big Three — Blink (Chrome/Edge), WebKit (Safari), and Mozilla (Firefox) — are working together to bring full and consistent support to them!

    You can read the blog posts yourself:

    An, yes, there’s plenty to get excited about specifically for CSS:

    Anchor positioning

    From our guide:

    CSS Anchor Positioning gives us a simple interface to attach elements next to others just by saying which sides to connect — directly in CSS. It also lets us set a fallback position so that we can avoid the overflow issues we just described.

    Advanced attr()

    We’ve actually had the attr() function for something like 15 years. But now we’re gonna be able to pass variables in there… with type conversion!

    Container style queries

    We can already query containers by “type” but only by size. It’ll be so much cooler when we can apply styles based on other styles. Say:

    @container style((font-style: italic) and (--color-mode: light)) {
      em, i, q {
        background: lightpink;
      }
    }

    The contrast-color() function

    Getting the right color contrast between foreground text and background can be easy enough, but it’s been more of a manual type thing that we might switch with a media query based on the current color scheme. With contrast-color() (I always want to write that as color-contrast(), maybe because that was the original name) we can dynamically toggle the color between white and black.

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

    Custom Highlights

    Highlight all the things! We’ve had ::selection forever, but now we’ll have a bunch of others:

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

    Dialogs and popovers

    Finally, a JavaScript-less (and declarative) way to set elements on the top layer! We’ve really dug into these over the years.

    Media pseudo-classes

    How often have you wanted to style an <audio> or <video> element based on its state? Perhaps with, JavaScript, right? We’ll have several states in CSS to work off:

    • :playing
    • :paused
    • :seeking
    • :buffering
    • :stalled
    • :muted
    • :volume-locked

    I love this example from the WebKit announcement:

    video:buffering::after {
      content: "Loading...";
    }

    Scroll-driven animations

    OK, we all want this one. We’re talking specifically about animation that responds to scrolling. In other words, there’s a direct link between scrolling progress and the animation’s progress.

    #progress {
      animation: grow-progress linear forwards;
      animation-timeline: scroll();
    }

    Scroll snapping

    Nothing new here, but bringing everyone in line with how the specs have changed over the years!

    The shape() function

    This is one that Temani has been all over lately and his SVG Path to Shape Converter is a must-bookmark. The shape() can draw complex shapes when clipping elements with the clip-path property. We’ve had the ability to draw basic shapes for years — think circleellipse(), and polygon() — but no “easy” way to draw more complex shapes. And now we have something less SVG-y that accepts CSS-y units, calculations, and whatnot.

    .clipped {
      width: 250px;
      height: 100px;
      box-sizing: border-box;
      background-color: blue;
      clip-path: shape(
        from top left,
        hline to 100%,
        vline to 100%,
        curve to 0% 100% with 50% 0%,
      );
    }

    View transitions

    There are two types of view transitions: same-document (transitions on the same page) and cross-document (or what we often call multi-page transitions). Same-page transitions went Baseline in 2025 and now browsers are working to be cross-compatible implementations of cross-document transitions.

    CSS zoom property

    Oh, I wasn’t expecting this! I mean, we’ve had zoom for years — our Almanac page was published back in 2011 — but as a non-standard property. I must have overlooked that it was Baseline 2024 newly available and worked on as part of Interop 2025. It’s carrying over into this year.

    zoom is sorta like the scale() function, but it actually affects the layout whereas scale() it’s merely visual and will run over anything in its way.


    That’s a wrap! Bookmark the Interop 2026 Dashboard to keep tabs on how things are progressing along.


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

  • What’s !important #5: Lazy-loading iframes, Repeating corner-shape Backgrounds, and More

    This issue of What’s !important is dedicated to our friends in the UK (aka me), who are currently experiencing a very miserable 43-day rain streak. Presenting: the five most interesting things to read about CSS from the last couple of weeks. Plus, the latest features from Chrome 145, and anything else you might’ve missed. TL;DR: lots of content, but also lots of rain.

    Why you can only code for 4 hours/day

    Don’t worry, you’re only coding for 52 minutes/day anyway.

    Dr. Milan Milanović talks about the devastating impact of meetings, emails, Slack, and interruptions, and what you/your manager can do about it. This article is a real eye-opener with a ton of shocking (but not surprising) statistics about the average developer’s flow state.

    Why you shouldn’t switch to smaller breakpoints too early

    Ahmad Shadeed explains why you shouldn’t switch to smaller responsive breakpoints too early, with examples of websites that’ve done so and scenarios in which users might hit those breakpoints.

    Source: Ahmad Shadeed.

    How to lazy-load above-the-fold iframes

    loading=lazy only works for off-screen elements, so Stefan Bauer demonstrates a neat trick for lazy-loading above-the-fold <iframe>s using <details>.

    How to create repeating corner-shape backgrounds

    Preethi Sam shows us how to use corner-shape in <svg>s, which are then used as repeating backgrounds. I’ve done my own experiments with corner-shape, but this is wonderful and certainly something that I hadn’t considered.

    The CSS Selection (2026 edition)

    What do web developers actually do with CSS? While other research studies look at features, The CSS Selection (2026 edition) focuses on CSS patterns and techniques. It’s a very interesting read, and you’ll definitely laugh once or twice, especially as you discover the different typos for !important.

    Here are some of my favorites:

    • !IMPORTANT: too shouty
    • !impotant: too much information
    • !i: that’s just lazy
    • !imPORTANT: excellent annunciation
    • !importantl: ah, so close…

    Chrome features and Quick Hits you might’ve missed

    Chrome 145 shipped a few days ago, and as always, we’ve been sharing some Quick Hits throughout the week. You can catch these in the sidebar of the homepage, so feel free to drop by if you’re ever in the ‘hood.

    Coincidentally, most of the Quick Hits were related to the Chrome update in some way, so I’ll recap everything together:

    • text-justify, which you can combine with text-align: justify to specify whether you want the word spacing (text-justify: inter-word) or letter spacing (text-justify: inter-character) to be adjusted to make the text justified. Geoff wrote about this way back in 2017 when only Firefox supported it (sort of…), so by my calculation, Safari should support it by 2035. So not this decade, but before GTA 6. Just kidding… (I think).
    • Speaking of word and letter spacing, word-spacing and letter-spacing now accept % units, as they do in Safari and Firefox.
    • Similarly, overscroll-behavior now works for non-root scroll containers, like in Safari and Firefox. WebDev RedFox’s warning about overscroll-behavior couldn’t have come at a better time.
    • column-wrap and column-height for better multicolumn layouts are also here now, but only in Chrome, unfortunately.
    • That also applies to customizable <select>, arguably the most exciting feature on this list. As I shared earlier in the week, Adam Argyle wonderfully boiled down this surprisingly complex feature to a simple outline that’s extremely easy to understand.
    • Looking a little more to the future now, it seems that we’ll eventually be able to have multiple borders and outlines on a single element as well as border-shape, as demonstrated by Dr. Lea Verou and Una Kravets respectively.

    Until next time!


    What’s !important #5: Lazy-loading iframes, Repeating corner-shape Backgrounds, and More originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Making a Responsive Pyramidal Grid With Modern CSS

    In the previous article, we built the classic hexagon grid. It was a responsive implementation without the use of media queries. The challenge was to improve a five-year old approach using modern CSS.

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

    In this article, we will explore another type of grid: a pyramidal one. We are still working with hexagon shapes, but a different organization of the elements.

    A demo worth a thousand words:

    For better visualization, open the full-page view of the demo to see the pyramidal structure. On screen resize, you get a responsive behavior where the bottom part starts to behave similarly to the grid we created in the previous article!

    Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

    Cool right? All of this was made without a single media query, JavaScript, or a ton of hacky CSS. You can chunk as many elements as you want, and everything will adjust perfectly.

    Before we start, do yourself a favor and read the previous article if you haven’t already. I will skip a few things I have already explained there, such as how the shapes are created as well as a few formulas I will reuse here. Similar to the previous article, the implementation of the pyramidal grid is an improvement of a five-year old approach, so if you want to make a comparison between 2021 and 2026, check out that older article as well.

    The Initial Configuration

    This time, we will rely on CSS Grid instead of Flexbox. With this structure, it’s easy to control the placement of items inside columns and rows rather than adjusting margins.

    <div class="container">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <!-- etc. -->
    </div>
    .container {
      --s: 40px;  /* size  */
      --g: 5px;   /* gap */
    
      display: grid;
      grid-template-columns: repeat(auto-fit, var(--s) var(--s));
      justify-content: center;
      gap: var(--g);
    }
    
    .container > * {
      grid-column-end: span 2;
      aspect-ratio: cos(30deg);
      border-radius: 50% / 25%;
      corner-shape: bevel;
      margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));
    }

    I am using the classic repeated auto-fit to create as many columns as the free space allows. For the items, it’s the same code of the previous article for creating hexagon shapes.

    You wrote var(--s) twice. Is that a typo?

    It’s not! I want my grid to always have an even number of columns, where each item spans two columns (that’s why I am using grid-column-end: span 2). With this configuration, I can easily control the shifting between the different rows.

    Zooming into the gap between hexagon shapes, which are highlighted in pink.

    Above is a screenshot of DevTools showing the grid structure. If, for example, item 2 spans columns 3 and 4, then item 4 should span columns 2 and 3, item 5 should span columns 4 and 5, and so on.

    It’s the same logic with the responsive part. Each first item of every other row is shifted by one column and starts on the second column.

    Zooming into the gap between hexagon shapes, which are highlighted in pink.

    With this configuration, the size of an item will be equal to 2*var(--s) + var(--g). For this reason, the negative bottom margin is different from the previous example.

    So, instead of this:

    margin-bottom: calc(var(--s)/(-4*cos(30deg)));

    …I am using:

    margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));

    Nothing fancy so far, but we already have 80% of the code. Believe it or not, we are only one property away from completing the entire grid. All we need to do is set the grid-column-start of a few elements to have the correct placement and, as you may have guessed, here comes the trickiest part involving a complex calculation.

    The Pyramidal Grid

    Let’s suppose the container is large enough to contain the pyramid with all the elements. In other words, we will ignore the responsive part for now. Let’s analyze the structure and try to identify the patterns:

    A stack of 28 hexagon shapes arranged in a pyramid-shaped grid. The first diagonal row on the right is highlighted showing how the shapes are aligned on the sides.

    Regardless of the number of items, the structure is somehow static. The items on the left (i.e., the first item of each row) are always the same (1, 2, 4, 7, 11, and so on). A trivial solution is to target them using the :nth-child() selector.

    :nth-child(1) { grid-column-start: ?? }
    :nth-child(2) { grid-column-start: ?? }
    :nth-child(4) { grid-column-start: ?? }
    :nth-child(7) { grid-column-start: ?? }
    :nth-child(11) { grid-column-start: ?? }
    /* etc. */

    The positions of all of them are linked. If item 1 is placed in column x, then item 2 should be placed in column x - 1, item 4 in column x - 2, and so forth.

    :nth-child(1) { grid-column-start: x - 0 } /* 0 is not need but useful to see the pattern*/
    :nth-child(2) { grid-column-start: x - 1 }
    :nth-child(4) { grid-column-start: x - 2 }
    :nth-child(7) { grid-column-start: x - 3 }
    :nth-child(11) { grid-column-start: x - 4 }
    /* etc. */

    Item 1 is logically placed in the middle, so if our grid contains N columns, then x is equal to N/2:

    :nth-child(1) { grid-column-start: N/2 - 0 }
    :nth-child(2) { grid-column-start: N/2 - 1 }
    :nth-child(4) { grid-column-start: N/2 - 2 }
    :nth-child(7) { grid-column-start: N/2 - 3 }
    :nth-child(11){ grid-column-start: N/2 - 4 }

    And since each item spans two columns, N/2 can also be seen as the number of items that can fit within the container. So, let’s update our logic and consider N to be the number of items instead of the number of columns.

    :nth-child(1) { grid-column-start: N - 0 }
    :nth-child(2) { grid-column-start: N - 1 }
    :nth-child(4) { grid-column-start: N - 2 }
    :nth-child(7) { grid-column-start: N - 3 }
    :nth-child(11){ grid-column-start: N - 4 }
    /* etc. */

    To calculate the number of items, I will use the same formula as in the previous article:

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

    The only difference is that the size of an item is no longer var(--s)but 2*var(--s) + var(--g), which gives us the following CSS:

    .container {
      --s: 40px;  /* size  */
      --g: 5px;   /* gap */
    
      container-type: inline-size; /* we make it a container to use 100cqw */
    }
    
    .container > * {
      --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
    }
    
    .container > *:nth-child(1) { grid-column-start: calc(var(--_n) - 0) }
    .container > *:nth-child(2) { grid-column-start: calc(var(--_n) - 1) }
    .container > *:nth-child(4) { grid-column-start: calc(var(--_n) - 2) }
    .container > *:nth-child(7) { grid-column-start: calc(var(--_n) - 3) }
    .container > *:nth-child(11){ grid-column-start: calc(var(--_n) - 4) }
    /* etc. */

    It works! We have our pyramidal structure. It’s not yet responsive, but we will get there. By the way, if your goal is to build such a structure with a fixed number of items, and you don’t need responsive behavior, then the above is perfect and you’re done!

    How come all the items are correctly placed? We only defined the column for a few items, and we didn’t specify any row!

    That’s the power of the auto-placement algorithm of CSS Grid. When you define the column for an item, the next one will be automatically placed after it! We don’t need to manually specify a bunch of columns and rows for all the items.

    Improving the Implementation

    You don’t like those verbose :nth-child() selectors, right? Me too, so let’s remove them and have a better implementation. Such a pyramid is well known in the math world, and we have something called a triangular number that I am going to use. Don’t worry, I will not start a math course, so here is the formula I will be using:

    j*(j + 1)/2 + 1 = index

    …where j is a positive integer (zero included).

    In theory, all the :nth-child can be generated using the following pseudo code:

    for(j = 0; j< ?? ;j++) {
      :nth-child(j*(j + 1)/2 + 1) { grid-column-start: N - j }
    }

    We don’t have loops in CSS, so I will follow the same logic I did in the previous article (which I hope you read, otherwise you will get a bit lost). I express j using the index. I solved the previous formula, which is a quadratic equation, but I am sure you don’t want to get into all that math.

    j = sqrt(2*index - 1.75) - .5

    We can get the index using the sibling-index() function. The logic is to test for each item if sqrt(2*index - 1.75) - .5 is a positive integer.

    .container {
      --s: 40px; /* size  */
      --g: 5px; /* gap */
    
      container-type: inline-size; /* we make it a container to use 100cqw */
    }
    .container > * {
      --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
      --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
      --_d: mod(var(--_j),1);
      grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););
    }

    When the --_d variable is equal to 0, it means that --_j is an integer; and when that’s the case I set the column to N - j. I don’t need to test if --_j is positive because it’s always positive. The smallest index value is 1, so the smallest value of --_j is 0.

    Tada! We replaced all the :nth-child() selectors with three lines of CSS that cover any number of items. Now let’s make it responsive!

    The Responsive Behavior

    Back in my 2021 article, I switched between the pyramidal grid and the classic grid based on screen size. I will do something different this time. I will keep building the pyramid until it’s no longer possible, and from there, it will turn into the classic grid.

    Showing a stack of hexagon shapes arranged in two shapes: on top is the pyramid grid and below that it becomes a rectangular grid.

    Items 1 to 28 form the pyramid. After that, we get the same classic grid we built in the previous article. We need to target the first items of some rows (29, 42, etc.) and shift them. We are not going to set a margin on the left this time, but we do need to set their grid-column-start value to 2.

    As usual, we identify the formula of the items, express it using the index, and then test if the result is a positive integer or not:

    N*i + (N - 1)*(i - 1) + 1 + N*(N - 1)/2 = index

    So:

    i = (index - 2 + N*(3 - N)/2)/(2*N - 1)

    When i is a positive integer (zero excluded), we set the column start to 2.

    .container {
      --s: 40px; /* size  */
      --g: 5px; /* gap */
    
      container-type: inline-size; /* we make it a container to use 100cqw */
    }
    .container > * {
      --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
    
      /* code for the pyramidal grid */
      --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
      --_d: mod(var(--_j),1);
      grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););
    
      /* code for the responsive grid */
      --_i: calc((sibling-index() - 2 + (var(--_n)*(3 - var(--_n)))/2)/(2*var(--_n) - 1));
      --_c: mod(var(--_i),1);
      grid-column-start: if(style((--_i > 0) and (--_c: 0)): 2;);
    }

    Unlike the --_j variable, I need to test if --_i is a positive value, as it can be negative for some index values. For this reason, I have an extra condition compared to the first one.

    But wait! That’s no good at all. We are declaring grid-column-start twice, so only one of them will get used. We should have only one declaration, and for that, we can combine both conditions using a single if() statement:

    grid-column-start:
    if(
      style((--_i > 0) and (--_c: 0)): 2; /* first condition */
      style(--_d: 0): calc(var(--_n) - var(--_j)); /* second condition */
    );

    If the first condition is true (the responsive grid), we set the value to 2; else if the second condition is true (the pyramidal grid), we set the value to calc(var(--_n) - var(--_j)); else we do nothing.

    Why that particular order?

    Because the responsive grid should have a higher priority. Check the figure below:

    Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

    Item 29 is part of the pyramidal grid since it’s the first item in its row. This means that the pyramidal condition will always be true for that item. But when the grid becomes responsive, that item becomes part of the responsive grid, and the other condition is also true. When both conditions are true, the responsive condition one should win; that’s why it’s the first condition we test.

    Let’s see this in play:

    Oops! The pyramid looks good, but after that, things get messy.

    To understand what is happening, let’s look specifically at item 37. If you check the previous figure, you will notice it’s part of the pyramidal structure. So, even if the grid becomes responsive, its condition is still true and it gets a column value from the formula calc(var(--_n) - var(--_j)) which is not good because we want to keep its default value for auto-placement. That’s the case for many items, so we need to fix them.

    To find the fix, let’s see how the values in the pyramid behave. They all follow the formula N - j, where j is a positive integer. If, for example, N is equal to 10 we get:

    10, 9, 8, 7, ... ,0, -1 , -2

    At certain points, the values become negative, and since negative values are valid, those items will be randomly placed, disrupting the grid. We need to ensure the negative values are ignored, and the default value is used instead.

    We use the following to keep only the positive value and transform all the negative ones into zeroes:

    max(0, var(--_n) - var(--_j))

    We set 0 as a minimum boundary (more on that here) and the values become:

    10, 9, 8, 7, ... , 0, 0, 0, 0

    We either get a positive value for the column or we get 0.

    But you said the value should be the default one and not 0.

    Yes, but 0 is an invalid value for grid-column-start, so using 0 means the browser will ignore it and fall back to the default value!

    Our new code is:

    grid-column-start:
      if(
        style((--_i > 0) and (--_c: 0)): 2; /* first condition */
        style(--_d: 0): max(0,var(--_n) - var(--_j)); /* second condition */
      );

    And it works!

    You can add as many items as you want, resize the screen, and everything will fit perfectly!

    More Examples

    Enough code and math! Let’s enjoy more variations using different shapes. I’ll let you dissect the code as homework.

    Rhombus grid

    You will notice a slightly different approach for setting the gap between the elements in the next three demos.

    Octagon grid

    Circle grid

    And the other hexagon grid:

    Conclusion

    Do you remember when I told you that we were one property away from completing the grid? That one property (grid-column-start) took us literally the whole article to discuss! This demonstrates that CSS has evolved and requires a new mindset to work with. CSS is no longer a language where you simply set static values such color: red, margin: 10px, display: flex, etc.

    Now we can define dynamic behaviors through complex calculations. It’s a whole process of thinking, finding formulas, defining variables, creating conditions, and so on. That’s not something new since I was able to do the same in 2021. However, we now have stronger features that allow us to have less hacky code and more flexible implementations.


    Making a Responsive Pyramidal Grid With Modern CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Approximating contrast-color() With Other CSS Features

    You have an element with a configurable background color, and you’d like to calculate whether the foreground text should be light or dark. Seems easy enough, especially knowing how mindful we ought to be with accessibility.

    There have been a few drafts of a specification function for this functionality, most recently, contrast-color() (formerly color-contrast()) in the CSS Color Module Level 5 draft. But with Safari and Firefox being the only browsers that have implemented it so far, the final version of this functionality is likely still a ways off. There has been a lot of functionality added to CSS in the meantime; enough that I wanted to see whether we could implement it in a cross-browser friendly way today. Here’s what I have:

    color: oklch(from <your color> round(1.21 - L) 0 0);

    Let me explain how I got here.

    WCAG 2.2

    WCAG provides the formulas it uses for calculating the contrast between two RGB colors and Stacie Arellano has described in great detail. It’s based on older methods, calculating the luminance of colors (how perceptually bright they appear) and even tries to clamp for the limitations of monitors and screen flare:

    L1 + 0.05 / L2 + 0.05

    …where the lighter color (L1) is on the top. Luminance ranges from 0 to 1, and this fraction is responsible for contrast ratios going from 1 (1.05/1.05) to 21 (1.05/.05).

    The formulas for calculating the luminance of RGB colors are even messier, but I’m only trying to determine whether white or black will have higher contrast with a given color, and can get away with simplifying a little bit. We end up with something like this:

    L = 0.1910(R/255+0.055)^2.4 + 0.6426(G/255+0.055)^2.4 + 0.0649(B/255+0.055)^2.4

    Which we can convert into CSS like this:

    calc(.1910*pow(r/255 + .055,2.4)+.6426*pow(g/255 + .055,2.4)+.0649*pow(b/255 + .055,2.4))

    We can make this whole thing round to 1 or 0 using round(), 1 for white and 0 for black:

    round(.67913 - .1910*pow(r/255 + .055, 2.4) - .6426*pow(g/255 + .055, 2.4) - .0649*pow(b/255 + .055, 2.4))

    Let’s multiply that by 255 and use it for all three channels with the relative color syntax. We end up with this:

    color: rgb(from <your color>  
      round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
      round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
      round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
    );

    A formula that, given a color, returns white or black based on WCAG 2. It’s not easy to read, but it works… except APCA is poised to replace it as a newer, better formula in future WCAG guidelines. We can do the math again, though APCA is an even more complicated formula. We could leverage CSS functions to clean it up a little, but ultimately this implementation is going to be inaccessible, hard to read, and difficult to maintain.

    New Approach

    I took a step back and thought about what else we have available. We do have another new feature we can try out: color spaces. The “L*” value in the CIELAB color space represents perceptual lightness. It is meant to reflect what our eyes can see. It’s not the same as luminance, but it’s close. Maybe we could guess whether to use black or white for better contrast based on perceptual lightness; let’s see if we can find a number where any color with lower lightness we use black, and higher lightness we use white.

    You might instinctively think it should be 50% or .5, but it isn’t. A lot of colors, even when they’re bright, still contrast better with white than black. Here’s some examples using lch(), slowly increasing the lightness while keeping the hue the same:

    The transition point where it’s easier to read the black text than white usually happens between 60-65. So, I put together a quick Node app using Colorjs.io to calculate where the cut off should be, using APCA for calculating contrast.

    For oklch(), I found the threshold to be between .65 and .72, with an average of .69.

    In other words:

    • When the OKLCH lightness is .72 or above, black will always contrast better than white.
    • Below .65, white will always contrast better than black.
    • Between .65 and .72, typically both black and white have contrasts between 45-60.

    So, just using round() and the upper bound of .72, we can make a new, shorter implementation:

    color: oklch(from <your color> round(1.21 - L) 0 0);

    If you’re wondering where 1.21 came from, it’s so that .72 rounds down and .71 rounds up: 1.21 - .72 = .49 rounds down, and 1.21 - .71 = .5 rounds up.

    This formula works pretty well, having put a couple iterations of this formula into production. It’s easier to read and maintain. That said, this formula more closely matches APCA than WCAG, so sometimes it disagrees with WCAG. For example, WCAG says black has a higher contrast (4.70 than white at 4.3) when placed on #407ac2, whereas APCA says the opposite: black has a contrast of 33.9, and white has a contrast of 75.7. The new CSS formula matches APCA and shows white:

    A blue rectangle with white and black text compared on top. White says APCA and black says WCAG.

    Arguably, this formula may do a better job than WCAG 2.0 because it more closely matches APCA. That said, you’ll still need to check accessibility, and if you’re held legally to WCAG instead of APCA, then maybe this newer simpler formula is less helpful to you.

    LCH vs. OKLCH

    I did run the numbers for both, and aside from OKLCH being designed to be a better replacement for LCH, I also found that the numbers support that OKLCH is a better choice.

    With LCH, the gap between too dark for black and too light for white is often bigger, and the gap moves around more. For example, #e862e5 through #fd76f9 are too dark for black and too light for white. With LCH, that runs between lightness 63 through 70; for OKLCH, it’s .7 through .77. The scaling of OKLCH lightness just better matches APCA.

    One Step Further

    While “most-contrast” will certainly be better, we can implement one more trick. Our current logic simply gives us white or black (which is what the color-contrast() function is currently limited to), but we can change this to give us white or another given color. So, for example, white or the base text color. Starting with this:

    color: oklch(from <your color> round(1.21 - L) 0 0);  
    
    /* becomes: */
    
    --white-or-black: oklch(from <your color> round(1.21 - L) 0 0);  
    color: rgb(  
      from color-mix(in srgb, var(--white-or-black), <base color>)  
      calc(2*r) calc(2*g) calc(2*b)  
    );

    It’s some clever math, but it isn’t pleasant to read:

    • If --white-or-black is white, color-mix() results in rgb(127.5, 127.5, 127.5) or brighter; doubled we’re at rgb(255, 255, 255) or higher, which is just white.
    • If --white-or-black is black, color-mix() cuts the value of each RGB channel by 50%; doubled we’re back to the original value of the <base color>.

    Unfortunately, this formula doesn’t work in Safari 18 and below, so you need to target Chrome, Safari 18+ and Firefox. However, it does give us a way with pure CSS to switch between white and a base text color, instead of white and black alone, and we can fallback to white and black in Safari <18.

    You can also rewrite these both using CSS Custom Functions, but those aren’t supported everywhere yet either:

    @function --white-black(--color) {  
      result: oklch(from var(--color) round(1.21 - l) 0 0);  
    }
    
    @function --white-or-base(--color, --base) {  
      result: rgb(from color-mix(in srgb, --white-black(var(--color)), var(--base)) calc(2*r) calc(2*g) calc(2*b));  
    }

    Conclusion

    I hope this technique works well for you, and I’d like to reiterate that the point of this approach — looking for a threshold and a simple formula — is to make the implementation flexible and easy to adapt to your needs. You can easily adjust the threshold to whatever works best for you.


    Approximating contrast-color() With Other CSS Features originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Trying to Make the Perfect Pie Chart in CSS

    Speaking of charts… When was the last time you had to use a pie chart? If you are one of those people who have to give presentations right and left, then congratulations! You are both in my personal hell… and also surrounded by pie charts. Luckily, I think I haven’t needed to use them in ages, or at least that was until recently.

    Last year, I volunteered to make ta webpage for a kids’ charity in México1. Everything was pretty standard, but the staff wanted some data displayed as pie charts on their landing page. They didn’t give us a lot of time, so I admit I took the easy route and used one of the many JavaScript libraries out there for making charts.

    It looked good, but deep down I felt dirty; pulling in a whole library for a couple of simple pie charts. Feels like the easy way out rather than crafting a real solution.

    I want to amend that. In this article, we’ll try making the perfect pie chart in CSS. That means avoiding as much JavaScript as possible while addressing major headaches that comes with handwriting pie charts. But first, let’s set some goals that our “perfect” should comply with.

    In order of priority:

    1. This must be semantic! Meaning a screen reader should be able to understand the data shown in the pie chart.
    2. This should be HTML-customizable! Once the CSS is done, we only have to change the markup to customize the pie chart.
    3. This should keep JavaScript to a minimum! No problem with JavaScript in general, it’s just more fun this way.

    Once we are done, we should get a pie chart like this one:

    A pie chart illustration in four segments differentiated by color. Each segment is labelled with a name and percentage.

    Is this too much to ask? Maybe, but we’ll try it anyways.

    Conic gradients suck aren’t the best

    We can’t talk about pie charts without talking first about conic gradients. If you’ve read anything related to the conic-gradient() function, then you’ve likely seen that they can be used to create simple pie charts in CSS. Heck, even I have said so in the almanac entry. Why not? If only with one element and a single line of CSS…

    .gradient {
      background: conic-gradient(blue 0% 12.5%, lightblue 12.5% 50%, navy 50% 100%);
    }

    We can have seemlessly perfect pie chart:

    However, this method blatantly breaks our first goal of semantic pie charts. As it’s later noted on the same entry:

    Do not use the conic-gradient() function to create a real pie chart, or any other infographics for that matter. They don’t hold any semantic meaning and should only be used decoratively.

    Remember that gradients are images, so displaying a gradient as a background-image doesn’t tell screen readers anything about the pie charts themselves; they only see an empty element.

    This also breaks our second rule of making pie charts HTML-customizable, since for each pie chart we’d have to change its corresponding CSS.

    So should we ditch conic-gradient() altogether? As much as I’d like to, its syntax is too good to pass so let’s at least try to up its shortcomings and see where that takes us.

    Improving semantics

    The first and most dramatic problem with conic-gradient() is its semantics. We want a rich markup with all the data laid out so it can be understood by screen readers. I must admit I don’t know the best way to semantically write that, but after testing with NVDA, I believe this is a good enough markup for the task:

    <figure>
      <figcaption>Candies sold last month</figcaption>
      <ul class="pie-chart">
        <li data-percentage="35" data-color="#ff6666"><strong>Chocolates</strong></li>
        <li data-percentage="25" data-color="#4fff66"><strong>Gummies</strong></li>
        <li data-percentage="25" data-color="#66ffff"><strong>Hard Candy</strong></li>
        <li data-percentage="15" data-color="#b366ff"><strong>Bubble Gum</strong></li>
      </ul>
    </figure>

    Ideally, this is all we need for our pie chart, and once styles are done, just editing the data-* attributes or adding new <li> elements should update our pie chart.

    Just one thing though: In its current state, the data-percentage attribute won’t be read out loud by screen readers, so we’ll have to append it to the end of each item as a pseudo-element. Just remember to add the “%” at the end so it also gets read:

    .pie-chart li::after {
      content: attr(data-percentage) "%";
    }

    So, is it accessible? It is, at least when testing in NVDA. Here it is in Windows:

    You may have some questions regarding why I chose this or that. If you trust me, let’s keep going, but if not, here is my thought process:

    Why use data-attributes instead of writing each percentage directly?

    We could easily write them inside each <li>, but using attributes we can get each percentage on CSS through the attr() function. And as we’ll see later it makes working with CSS a whole lot easier.

    Why <figure>?

    The <figure> element can be used as a self-contained wrapper for our pie chart, and besides images, it’s used a lot for diagrams too. It comes in handy since we can give it a title inside <figcaption> and then write out the data on an unordered list, which I didn’t know was among the content permitted inside <figure> since <ul> is considered flow content.

    Why not use ARIA attributes?

    We could have used an aria-description attribute so screen readers can read the corresponding percentage for each item, which is arguably the most important part. However, we may need to visually show the legend, too. That means there is no advantage to having percentages both semantically and visually since they might get read twice: (1) once on the aria-description and (2) again on the pseudo-element.

    Making it a pie chart

    We have our data on paper. Now it’s time to make it look like an actual pie chart. My first thought was, “This should be easy, with the markup done, we can now use a conic-gradient()!”

    Well… I was very wrong, but not because of semantics, but how the CSS Cascade works.

    Let’s peek again at the conic-gradient() syntax. If we have the following data:

    • Item 1: 15%
    • Item 2: 35%
    • Item 3: 50%

    …then we would write down the following conic-gradient():

    .gradient {
      background: 
        conic-gradient(
          blue 0% 15%, 
          lightblue 15% 50%, 
          navy 50% 100%
        );
    }

    This basically says: “Paint the first color from 0 to 15%, the next color from 15% to 50% (so the difference is 35%), and so on.”

    Do you see the issue? The pie chart is drawn in a single conic-gradient(), which equals a single element. You may not see it, but that’s terrible! If we want to show each item’s weight inside data-percentage — making everything prettier — then we would need a way to access all these percentages from the parent element. That’s impossible!

    The only way we can get away with the simplicity of data-percentage is if each item draws its own slice. This doesn’t mean, however, that we can’t use conic-gradient(), but rather we’ll have to use more than one.

    The plan is for each of these items to have their own conic-gradient() painting their slice and then place them all on top of each other:

    Four separated pie slices on the left, combined into a complete pie chart on the right.

    To do this, we’ll first give each <li> some dimensions. Instead of hardcoding a size, we’ll define a --radius property that’ll come in handy later for keeping our styles maintainable when updating the HTML.

    .pie-chart li {
      --radius: 20vmin;
    
      width: calc(var(--radius) * 2); /* radius twice = diameter */
      aspect-ratio: 1;
      border-radius: 50%;
    }

    Then, we’ll get the data-percentage attribute into CSS using attr() and its new type syntax that allows us to parse attributes as something other than a string. Just beware that the new syntax is currently limited to Chromium as I’m writing this.

    However, in CSS it is far better to work with decimals (like 0.1) instead of percentages (like 10%) because we can multiply them by other units. So we’ll parse the data-percentage attribute as a <number> and then divide it by 100 to get our percentage in decimal form.

    .pie-chart li {
      /* ... */
      --weighing: calc(attr(data-percentage type(<number>)) / 100);
    }

    We still need it as a percentage, which means multiplying that result by 1%.

    .pie-chart li {
      /* ... */
      --percentage: calc(attr(data-percentage type(<number>)) * 1%);
    }

    Lastly, we’ll get the data-color attribute from the HTML using attr() again, but with the <color> type this time instead of a <number>:

    .pie-chart li {
      /* ... */
      --bg-color: attr(data-color type(<color>));
    }

    Let’s put the --weighing variable aside for now and use our other two variables to create the conic-gradient() slices. These should go from 0% to the desired percentage, and then become transparent afterwards:

    .pie-chart li {
      /* ... */
       background: conic-gradient(
       var(--bg-color) 0% var(--percentage),
       transparent var(--percentage) 100%
      );
    }

    I am defining the starting 0% and ending 100% explicitly, but since those are the default values, we could technically remove them.

    Here’s where we’re at:

    Perhaps an image will help if your browser lacks support for the new attr() syntax:

    Four slices of a pie arranged on a single row from left to right. Each slice is differentiated by color and a white label with a percentage value.

    Now that all the slices are done, you’ll notice each of them starts from the top and goes in a clockwise direction. We need to position these, you know, in a pie shape, so our next step is to rotate them appropriately to form a circle.

    This is when we hit a problem: the amount each slice rotates depends on the number of items that precede it. We’ll have to rotate an item by whatever size the slice before it is. It would be ideal to have an accumulator variable (like --accum) that holds the sum of the percentages before each item. However, due to the way the CSS Cascade works, we can neither share state between siblings nor update the variable on each sibling.

    And believe me, I tried really hard to work around these issues. But it seems we are forced into two options:

    1. Hardcode the --accum variable on each <li> element.
    2. Use JavaScript to calculate the --accum variable.

    The choice isn’t that hard if we revisit our goals: hardcoding --accum would negate flexible HTML since moving an item or changing percentages would force us to manually calculate the --accum variable again.

    JavaScript, however, makes this a trivial effort:

    const pieChartItems = document.querySelectorAll(".pie-chart li");
    
    let accum = 0;
    
    pieChartItems.forEach((item) =>; {
      item.style.setProperty("--accum", accum);
      accum += parseFloat(item.getAttribute("data-percentage"));
    });

    With --accum out of the way, we can rotate each conic-gradient() using the from syntax, that tells the conic gradient the rotation’s starting point. The thing is that it only takes an angle, not a percentage. (I feel like a percentage should also work fine, but that’s a topic for another time).

    To work around this, we’ll have to create yet another variable — let’s call it --offset — that is equal to --accum converted to an angle. That way, we can plug the value into each conic-gradient():

    .pie-chart li {
      /* ... */
      --offset: calc(360deg * var(--accum) / 100);
    
      background: conic-gradient(
        from var(--offset),
        var(--bg-color) 0% var(--percentage),
        transparent var(--percentage) 100%
      );
    }

    We’re looking a lot better!

    Pie chart slices arranges on a single row, with each slices properly rotated. All that's let is to arrange the slices in a circular shape.

    What’s left is to place all items on top of each other. There are plenty of ways to do this, of course, though the easiest might be CSS Grid.

    .pie-chart {
      display: grid;
      place-items: center;
    }
    
    .pie-chart li {
      /* ... */
      grid-row: 1;
      grid-column: 1;
    }

    This little bit of CSS arranges all of the slices in the dead center of the .pie-chart container, where each slice covers the container’s only row and column. They slices won’t collide because they’re properly rotated!

    A pie chart four segments differentiated by color. The segment labels are illegible because they are stacked on top of one another in the top-left corner.

    Except for those overlapping labels, we’re in really, really good shape! Let’s clean that stuff up.

    Positioning labels

    Right now, the name and percentage labels inside the <figcaption> are splattered on top of one another. We want them floating next to their respective slices. To fix this, let’s start by moving all those items to the center of the .pie-chart container using the same grid-centering trick we we applied on the container itself:

    .pie-chart li {
      /* ... */
      display: grid;
      place-items: center;
    }
    
    .pie-chart li::after,
    strong {
      grid-row: 1;
      grid-column: 1;
    }

    Luckily, I’ve already explored how to lay things out in a circle using the newer CSS cos() and sin(). Give those links a read because there’s a lot of context in there. In short, given an angle and a radius, we can use cos() and sin() to get the X and Y coordinates for each item around a circle.

    For that, we’ll need — you guessed it! — another CSS variable representing the angle (we’ll call it --theta) where we’ll place each label. We can calculate that angle this next formula:

    .pie-chart li {
      /* ... */
      --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg);
    }

    It’s worth knowing what that formula is doing:

    • 360deg * var(--weighing)) / 2: Gets the percentage as an angle then divides it by two to find the middle point.
    • + var(--offset): Moves the angle to match the current offset.
    • - 90degcos() and sin(): The angles are measured from the right, but conic-gradient() starts from the top. This part corrects each angle by -90deg.

    We can find the X and Y coordinates using the --theta and --radius variables, like the following pseudo code:

    x = cos(theta) * radius
    y = sin(theta) * radius

    Which translates to…

    .pie-chart li {
      /* ... */
      --pos-x: calc(cos(var(--theta)) * var(--radius));
      --pos-y: calc(sin(var(--theta)) * var(--radius));
    }

    This places each item on the pie chart’s edge, so we’ll add in a --gap between them:

    .pie-chart li {
      /* ... */
      --gap: 4rem;
      --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap)));
      --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)));
    }

    And we’ll translate each label by --pos-x and --pos-y:

    .pie-chart li::after,
    strong {
      /* ... */
      transform: translateX(var(--pos-x)) translateY(var(--pos-y));
    }

    Oh wait, just one more minor detail. The label and percentage for each item are still stacked on top of each other. Luckily, fixing it is as easy as translating the percentage a little more on the Y-axis:

    .pie-chart li::after {
      --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh);
    }

    Now we’re cooking with gas!

    A pie chart illustration in four segments differentiated by color. Each segment is labelled with a name and percentage.

    Let’s make sure this is screenreader-friendly:

    That’s about it… for now…

    I’d call this a really good start toward a “perfect” pie chart, but there are still several things we could improve:

    • The pie chart assumes you’ll write the percentages yourself, but there should be a way to input the raw number of items and then calculate their percentages.
    • The data-color attribute is fine, but if it isn’t provided, we should still provide a way to let CSS generate the colors. Perhaps a good job for color-mix()?
    • What about different types of charts? Bar charts, anyone?
    • This is sorta screaming for a nice hover effect, like maybe scaling a slice and revealing it?

    That’s all I could come up with for now, but I’m already planning to chip away at those at follow up with another piece (get it?!). Also, nothing is perfect without lots of feedback, so let me know what you would change or add to this pie chart so it can be truly perfect!


    1 They are great people helping kids through extremely difficult times, so if you are interested in donating, you can find more on their socials. ↪️


    Trying to Make the Perfect Pie Chart in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Nexoglyph
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.