Category: SEO Clarity

  • Abusing Customizable Selects

    Web browsers ship new features all the time, but what fun is it if we can’t build silly and fun things with them?

    In this article, let’s go over a few demos that I’ve made by using the new customizable <select> feature, and walk through the main steps and techniques that I’ve used to implement them.

    I hope they get you as excited as I am about custom selects, and give you just about enough knowledge to get started creating your own. Yours might be more, you know, useful than mine, and probably for good reasons, but I like going a little bit overboard on silly ideas because that gives me a better chance to learn.

    Before we start, a word about browser support: the demos in this article only run on recent Chromium-based browsers because that’s where customizable selects are implemented right now. However, this feature is designed in a way that doesn’t break non-supporting browsers. After all, a customized <select> element is still a <select> element. So, if the browser you’re using doesn’t support customizable selects, you’ll just see normal selects and options in these demos, and that’s great. It’ll just be a lot less fun.

    Curved stack of folders

    Let’s get started with the first demo: a stack of folders to pick from, with a twist:

    We’ll start with some HTML code first. We don’t need a lot of complicated markup here because each option is just the name of the folder. We can draw the folder icons later with CSS only.

    <select>
      <option value="documents"><span>Documents</span></option>
      <option value="photos"><span>Photos</span></option>
      <option value="music"><span>Music</span></option>
      <option value="videos"><span>Videos</span></option>
      <option value="downloads"><span>Downloads</span></option>
      <option value="desktop"><span>Desktop</span></option>
      <option value="projects"><span>Projects</span></option>
      <option value="backups"><span>Backups</span></option>
      <option value="trash"><span>Trash</span></option>
    </select>

    You’ll notice that we’ve used <span> elements inside the <option> elements, to wrap each folder name. That’s going to be useful for styling the selected folder name later. Even though this is just a <span>, being able to do this is quite a big change from what was previously possible.

    That’s because, up until very recently, <option>s could only contain text, because that’s the only thing that could appear inside options of a select. The HTML parser has now been relaxed to allow for a lot more HTML elements to be embedded in options. Browsers that don’t support customizable selects will just ignore these extra elements and display the text only.

    So, here’s what our stack of folders looks like so far:

    An unstyled select element with expanded options.

    Next up, and this is the most important thing you’ll want to do to opt into the customizable select feature: let’s reset the default appearance of the select and its dropdown part, by using the ::picker() pseudo-element:

    select,
    ::picker(select) {
      appearance: base-select;
    }

    This CSS rule does a lot for us: it unlocks full styling capabilities for the entire select, including its button, dropdown, and options. Without this opt-in, you get a standard select.

    Now let’s style the select, starting with its button part. First, we’ll get rid of the picker icon by using the new ::picker-icon pseudo-element to hide it:

    select::picker-icon {
      display: none;
    }

    Next, let’s add a bit more styles to create a nice-looking button:

    select {
      background: linear-gradient(
        135deg,
        rgba(40, 40, 50, 0.4) 0%,
        rgba(60, 60, 70, 0.25) 50%,
        rgba(50, 50, 60, 0.35) 100%
      );
      backdrop-filter: blur(12px) saturate(180%);
      box-shadow:
        0 8px 32px rgba(0, 0, 0, 0.2),
        inset 0 1px 1px rgba(255, 255, 255, 0.15),
        inset 0 -1px 1px rgba(0, 0, 0, 0.1);
      border: 1px solid rgba(255, 255, 255, 0.2);
      color: white;
      min-inline-size: 12rem;
    }

    And here is our new select button:

    A custom select button with an opaque background, a folder icon, and a text label called Music.

    Now let’s turn our attention to the dropdown part since this is where the magic happens.

    In a select, the dropdown contains all the options and appears when you click on the button. A lot of browser default styles apply to it already to set its position, background-color, margin, and more. So, we’ll have to disable and override a bunch of stuff.

    In our demo, we don’t want the dropdown to be visible at all. Instead, we want each individual option (each folder in this case) to appear as if floating above the page, without a container element.

    To do this, let’s use the ::picker(select) pseudo-element to set our styles:

    ::picker(select) {
      background: transparent;
      border: none;
      box-shadow: none;
      overflow: visible;
    }

    And with this, the dropdown isn’t visible anymore and it no longer constrains the options or clips them if they overflow the dropdown area.

    This gives us the following improvements:

    A select element with expanded options formatted as text in a single vertical list. An option called music is selected and represents the top picker button which is styled with a folder icon to the left of the text label.

    It’s now time to turn our attention to the option elements. First, let’s replace the checkmark icon with a little disc icon instead by using the ::checkmark pseudo-element:

    option::checkmark {
      content: "●";
      color: #222;
    }

    This pseudo-element makes it easy to change the shape, the color, or even the size of the checkmark.

    Let’s also add an additional pseudo-element to each option, by using option::before, to display a folder emoji next to each option. And, with a pinch more CSS fine tuning, we end up with this:

    A vertical column of folder icons expanded as options from a select element. Each folder includes a label on the right.

    We now have a list of folders which floats on top of the page when we click the select button. It works like any other select, too, either with the mouse, or with the keyboard, so we can just thank the browser for maintaining the accessibility of the input while we’re having fun with CSS.

    Let’s now apply some CSS transformation to make the stack of folders a little curvy, so it looks cooler.

    To achieve this, we’ll need one more piece of new CSS syntax which, unfortunately, isn’t yet widely available: the sibling-index() function. This function returns the index of the element within its siblings. The sibling-count() function also exists, and it returns the total number of siblings, but we won’t need it here.

    Having access to the index of the current element within its siblings means that we can style each option depending on its position within the select dropdown. This is exactly what we need to make the options appear at a gradually larger angle.

    Here is the code:

    option {
      --rotation-offset: -4deg;
      rotate: calc(sibling-index() * var(--rotation-offset));
    }

    In this code snippet, we first create a custom property called --rotation-offset, which defines the angle by which each option should rotate, with respect to the previous option. We then use this with the rotate property, multiplying its value by sibling-index(). That way, the first option is rotated by -4 degrees, the second one by -8 degrees, the third by -12 degrees, and so on.

    Now, that’s not enough on its own to create the illusion of a curved stack of folders because each folder rotates around its own point of origin, which is located in the top-left corner of each folder by default. Right now, we get this:

    A single column of folder icons with labels on the right. Each folder is slightly rotated more as the list goes down.

    Let’s use the transform-origin property to set a shared point of origin around which all options will rotate. Because transform-origin is relative to each individual element, we need to use the sibling-index() function again to move all origin points up and to the right so they’re all in the same spot:

    option {
      --rotation-offset: -4deg;
      rotate: calc(sibling-index() * var(--rotation-offset));
      transform-origin: right calc(sibling-index() * -1.5rem);
    }

    And with this, we get the following result:

    A vertical column of folders with labels on the right fanned out and curving towards the right.

    The final step is to animate the options. It looks great as it is, but we want the stack of folders to get gradually curved until it reaches its final shape. That’ll make it a lore more lively and fun to interact with.

    Let’s reset the option’s rotation by default, and apply a transition with a nice elastic easing function:

    option {
      rotate: 0deg;
      transition: rotate 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
    }

    And now, let’s apply the right rotation angle only when the select is open:

    select:open option {
      rotate: calc(sibling-index() * -1 *  var(--rotation-offset));
    }

    Unfortunately, the above is not enough. By default, CSS transitions are not triggered when an element appears, which is the case for our options. Thankfully, there’s a fix for this issue: the @starting-style at-rule. This at-rule lets us define the initial state of the options, making it possible for the transition to play right when the options appear:

    @starting-style {
      select:open option {
        rotate: 0deg;
      }
    }

    One more thing to make it even nicer. Let’s delay each transition relative to the previous one to make it look like each folder comes in slightly after the one before it. To achieve this, let’s use the sibling-index() function once more, as a multiplier to a short transition delay:

    option {
      transition-delay: calc((sibling-index() - 1) * 0.01s);
    }

    We now have an animated, curved, stack of folders implemented with a <select> element! Check out the demo and code in the next CodePen:

    CSS gains a lot of new capabilities each year. I hope this demo walk through helped you get a better understanding of some of these new capabilities. Building it helped me understand a lot of new, to me, concepts. It also got me very excited about the customizable select feature. So much, that I created other demos too. So, let’s look at two more of them. This time though, we’ll go quicker and only highlight the most important parts.

    Fanned deck of cards

    For our second demo, we’ll create a card picker, which opens up in a fanned deck fashion:

    The HTML markup for this demo is a little different than for the previous one. Each card has a bit of content to display, so let’s create a couple of <span> elements to each option:

    <option class="red" value="QH">
      <span class="rank">Q</span>
      <span class="suit">♥</span>
    </option>

    The other interesting thing about the HTML code we’ll use here, is the addition of an empty <button> element right below the <select> opening tag:

    <select>
      <button></button>
      <option>…</option>
      <!-- ... -->
    </select>

    This empty <button> serves a very specific purpose: it prevents the default <selectedcontent> behavior from happening.

    In a customized select, the browser automatically displays the currently selected option’s content (in this case, the card face) in the button area of the select. And it does this by creating an element named <selectedcontent> which mirrors the selected option. But, in our demo, we want the button to always show the back of the deck of cards, not the selected card. To achieve this, we override the default behavior by introducing our own <button>. This tells the browser not to insert its own <selectedcontent> element and lets us style the <select> element:

    select {
      background:
        /* Diamond pattern overlay */
        repeating-linear-gradient(45deg,
          transparent,
          transparent 1vmin,
          rgba(255, 255, 255, 0.05) 1vmin,
          rgba(255, 255, 255, 0.05) 2vmin),
        repeating-linear-gradient(-45deg,
          transparent,
          transparent 1vmin,
          rgba(255, 255, 255, 0.05) 1vmin,
          rgba(255, 255, 255, 0.05) 2vmin),
        /* Base gradient */
        linear-gradient(135deg, #8b0000 0%, #dc143c 50%, #8b0000 100%);
    }
    A single card with its back showing in red.

    Now, for the dropdown part, just like in the previous demo, we don’t want the dropdown container element to be visible, so we’ll also override the default background, border, and overflow styles like we did before.

    More importantly, the position of the deck of cards, when opened, is very important. We want it to fan out from the deck itself and remain centered above it.

    In a customizable select, the dropdown part, i.e., the ::picker(select) pseudo-element, is positioned relative to the button part thanks to anchor positioning, which is great because we can override it!

    In our case, let’s override the alignment relative to the anchor, which is the button, by using the position-area property:

    ::picker(select) {
      position-area: center center;
      inset: 0;
    }

    We’re also setting the inset property to 0 here. This sets all top, right, bottom, and left properties to 0 in a single declaration, which makes the dropdown part able to use the entire available space, rather than being constrained by the browser to appear on the side of the select button.

    Finally, let’s make the cards appear side by side, rather than above each other:

    select:open::picker(select) {
      display: flex;
    }

    When the select element is open and the options are visible, we now see this:

    Nice cards lined up in a single row. Each card slightly overlaps.

    The next step is to rotate each card so the options appear in a fanned out way, with the center card straight, the cards to the left gradually more rotated towards the left, and the cards to the right rotated towards the right.

    To do this, you’ve guessed it, we’ll use the sibling-index() property again. We’ll also use the sibling-count() property this time:

    option {
      --card-fan-rotation: 7deg;
      --card-fan-spread: -11vmin;
      --option-index: calc(sibling-index() - 1);
      --center: calc(sibling-count() / 2);
      --offset-from-center: calc(var(--option-index) - var(--center));
    
      rotate: calc(var(--offset-from-center) * var(--card-fan-rotation));
      translate: calc(var(--offset-from-center) * var(--card-fan-spread)) 0;
      transform-origin: center 75vmin;
    }

    In the above code snippet, we’re calculating the offset of each card relative to the center card, and we’re using this to rotate each card by increments of 7 degrees. For example, in a deck with 9 cards, the left-most card (i.e., the first card) will get a -4 offset, and will be rotated by -4 * 7 = -28 degrees, while the right-most card will be rotated by 28 degrees.

    We also use the translate property to bring the cards close together into a fan, and the `transform-origin` property to make it all look perfect.

    Nice cards fanned out in a subtle arc.

     Finally, let’s bring it all together by animating the opening of the deck. To do this, we can define a CSS transition on the custom --card-fan-rotation property. Animating it from 0 to 7 degrees is all we need to create the illusion we’re after. Animating a custom property takes a couple of steps.

    First, let’s define the custom property’s type, so that the browser can animate it correctly:

    @property --card-fan-rotation {
      syntax: '<angle>';
      inherits: false;
      initial-value: 7deg;
    }

    Second, let’s use a @starting-style at-rule, like in the previous demo, to allow the CSS transition to play when the options appear:

    @starting-style {
      select:open option {
      --card-fan-rotation: 0deg;
      }
    }

    Then, set the starting rotation angle when the select element is closed, and define the CSS transition:

    option {
      --card-fan-rotation: 0deg;
      transition: --card-fan-rotation 0.2s ease-out;
    }

    And, finally, let’s set the final angle when the select is opened:

    select:open option {
      --card-fan-rotation: initial;
    }

    We can use the `initial` value above instead of hard-coding the 7deg value again, since it’s already defined as the initial value in the @property rule above.

    That’s it, our deck of cards, with animated opening, is now ready! Check out the complete code and live demo in this CodePen:

    It’s amazing to me how far customizable selects allow you to push things. You don’t only get to override the way the button and its options look, you get to change how everything is positioned, and even animated.

    Let’s close with one final demo.

    Radial emoji picker

    Just like in the previous demo, here we want the emojis to be centered around the select button. To achieve this, let’s override the default anchor positioning of the dropdown part.

    This time, we’ll use the anchor() function to set the top and left coordinates of the dropdown container:

    ::picker(select) {
      top: calc(anchor(top) - var(--radius));
      left: calc(anchor(left) - var(--radius));
      width: calc(var(--radius) * 2 + var(--option-size));
      height: calc(var(--radius) * 2 + var(--option-size));
    }

    In this code snippet, the --radius property is the radius of the circle of emojis. And, since customizable selects already use anchor positioning, we can use the anchor() function to position the dropdown relative to the button.

    Now we need to position the options in a circle, inside the dropdown. As it turns out, CSS knows trigonometry now, too, so we’ll use the cos() and sin() functions together with the sibling-index() and sibling-count() functions:

    option {
      position: absolute;
      --angle: calc((sibling-index() - 2) * (360deg / (sibling-count() - 1)) - 90deg);
      top: 50%;
      left: 50%;
      translate:
        calc(-50% + cos(var(--angle)) * var(--radius)) calc(-50% + sin(var(--angle)) * var(--radius));
    }

    And there we are:

    Circular options with icons around another circular item in the center with a star icon.

    The final demo also contains a bit of code for animating the opening of the options, but we won’t dig into the details in this article.

    To learn more and play with the live demo, check out this CodePen:

    Wrapping up

    That’s it for now. I hope these demos have given you a bit more of an understanding for how customizable selects are customized, and some excitement for actually using the feature in a real project.

    Keep in mind, even when customized, the element is still a <select> and will work just fine in non-supporting browsers. So, even if the feature is still in its early days, you can use it as a great progressive enhancement.


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

  • The Value of z-index

    The z-index property is one of the most important tools any UI developer has at their disposal, as it allows you to control the stacking order of elements on a webpage. Modals, toasts, popups, dropdowns, tooltips, and many other common elements rely on it to ensure they appear above other content.

    While most resources focus on the technical details or the common pitfalls of the Stacking Context (we’ll get to that in a moment…), I think they miss one of the most important and potentially chaotic aspects of z-index: the value.

    Screenshot of a code editor with a large number of z-index values, many of which include the !important keyword.

    In most projects, once you hit a certain size, the z-index values become a mess of “magic numbers”, a chaotic battlefield of values, where every team tries to outdo the others with higher and higher numbers.

    How This Idea Started

    I saw this line on a pull request a few years ago:

    z-index: 10001;

    I thought to myself, “Wow, that’s a big number! I wonder why they chose that specific value?” When I asked the author, they said: “Well, I just wanted to make sure it was above all the other elements on the page, so I chose a high number.”

    This got me thinking about how we look at the stacking order of our projects, how we choose z-index values, and more importantly, the implications of those choices.

    The Fear of Being Hidden

    The core issue isn’t a technical one, but a lack of visibility. In a large project with multiple teams, you don’t always know what else is floating on the screen. There might be a toast notification from Team A, a cookie banner from Team B, or a modal from the marketing SDK.

    The developer’s logic was simple in this case: “If I use a really high number, surely it will be on top.”

    This is how we end up with magic numbers, these arbitrary values that aren’t connected to the rest of the application. They are guesses made in isolation, hoping to win the “arms race” of z-index values.

    We’re Not Talking About Stacking Context… But…

    As I mentioned at the beginning, there are many resources that cover z-index in the context of the Stacking Context. In this article, we won’t cover that topic. However, it’s impossible to talk about z-index values without at least mentioning it, as it’s a crucial concept to understand.

    Essentially, elements with a higher z-index value will be displayed in front of those with a lower value as long as they are in the same Stacking Context.

    If they aren’t, then even if you set a massive z-index value on an element in a “lower” stack, elements in a “higher” stack will stay on top of it, even if they have a very low z-index value. This means that sometimes, even if you give an element the maximum possible value, it can still end up being hidden behind something else.

    Now let’s get back to the values.

    💡 Did you know? The maximum value for z-index is 2147483647. Why this specific number? It’s the maximum value for a 32-bit signed integer. If you try to go any higher, most browsers will simply clamp it to this limit.

    The Problem With “Magic Numbers”

    Using arbitrary high values for z-index can lead to several issues:

    1. Lack of maintainability: When you see a z-index value like 10001, it doesn’t tell you anything about its relationship to other elements. It’s just a number that was chosen without any context.
    2. Potential for conflicts: If multiple teams or developers are using high z-index values, they might end up conflicting with each other, leading to unexpected behavior where some elements are hidden behind others.
    3. Difficult to debug: When something goes wrong with the stacking order, it can be challenging to figure out why, especially if there are many elements with high z-index values.A Better Approach

    I’ve encountered this “arms race” in almost every large project I’ve been a part of. The moment you have multiple teams working in the same codebase without a standardized system, chaos eventually takes over.

    The solution is actually quite simple: tokenization of z-index values.

    Now, wait, stay with me! I know that the moment someone mentions “tokens”, some developers might roll their eyes or shake their heads, but this approach actually works. Most of the major (and better-designed) design systems include z-index tokens for a reason. Teams that adopt them swear by them and never look back.

    By using tokens, you gain:

    • Simple and easy maintenance: You manage values in one place.
    • Conflict prevention: No more guessing if 100 is higher than whatever Team B is using.
    • Easier debugging:: You can see exactly which “layer” an element belongs to.
    • Better Stacking Context management: It forces you to think about layers systematically rather than as random numbers.

    A Practical Example

    Let’s look at how this works in practice. I’ve prepared a simple demo where we manage our layers through a central set of tokens in the :root:

    :root {
      --z-base: 0;
      --z-toast: 100;
      --z-popup: 200;
      --z-overlay: 300;
    }

    This setup is incredibly convenient. If you need to add a new popup or a toast, you know exactly which z-index to use. If you want to change the order — for example, to place toasts above the overlay — you don’t need to hunt through dozens of files. You just change the values in the :root, and everything updates accordingly in one place.

    Handling New Elements

    The real power of this system shines when your requirements change. Suppose you need to add a new sidebar and place it specifically between the base content and the toasts.

    In a traditional setup, you’d be checking every existing element to see what numbers they use. With tokens, we simply insert a new token and adjust the scale:

    :root {
      --z-base: 0;
      --z-sidebar: 100;
      --z-toast: 200;
      --z-popup: 300;
      --z-overlay: 400;
    }

    You don’t have to touch a single existing component with this setup. You update the tokens and you’re good to go. The logic of your application remains consistent, and you’re no longer guessing which number is “high enough”.

    The Power of Relative Layering

    We sometimes want to “lock” specific layers relative to each other. A great example of this is a background element for a modal or an overlay. Instead of creating a separate token for the background, we can calculate its position relative to the main layer.

    Using calc() allows us to maintain a strict relationship between elements that always belong together:

    .overlay-background {
      z-index: calc(var(--z-overlay) - 1);
    }

    This ensures that the background will always stay exactly one step behind the overlay, no matter what value we assign to the --z-overlay token.

    Managing Internal Layers

    Up until now, we’ve focused on the main, global layers of the application. But what happens inside those layers?

    The tokens we created for the main layers (like 100, 200, etc.) are not suitable for managing internal elements. This is because most of these main components create their own Stacking Context. Inside a popup that has z-index: 300, a value of 301 is functionally identical to 1. Using large global tokens for internal positioning is confusing and unnecessary.

    Note: For these local tokens to work as expected, you must ensure the container creates a Stacking Context. If you’re working on a component that doesn’t already have one (e.g., it doesn’t has a z-index set), you can create one explicitly using isolation: isolate.

    To solve this, we can introduce a pair of “local” tokens specifically for internal use:

    :root {
      /* ... global tokens ... */
    
      --z-bottom: -10;
      --z-top: 10;
    }

    This allows us to handle internal positioning with precision. If you need a floating action button inside a popup to stay on top, or a decorative icon on a toast to sit behind the main content, you can use these local anchors:

    .popup-close-button {
      z-index: var(--z-top);
    }
    
    .toast-decorative-icon {
      z-index: var(--z-bottom);
    }

    For even more complex internal layouts, you can still use calc() with these local tokens. If you have multiple elements stacking within a component, calc(var(--z-top) + 1) (or - 1) gives you that extra bit of precision without ever needing to look at global values.

    This keeps our logic consistent: we think about layers and positions systematically, rather than throwing random numbers at the problem and hoping for the best.

    Versatile Components: The Tooltip Case

    One of the biggest headaches in CSS is managing components that can appear anywhere, like a tooltip.

    Traditionally, developers give tooltips a massive z-index (like 9999) because they might appear over a modal. But if the tooltip is physically inside the modal’s DOM structure, its z-index is only relative to that modal anyway.

    A tooltip simply needs to be above the content it’s attached to. By using our local tokens, we can stop the guessing game:

    .tooltip {
      z-index: var(--z-top);
    }

    Whether the tooltip is on a button in the main content, an icon inside a toast, or a link within a popup, it will always appear correctly above its immediate surroundings. It doesn’t need to know about the global “arms race” because it’s already standing on the “stable floor” provided by its parent layer’s token.

    Negative Values Can Be Good

    Negative values often scare developers. We worry that an element with z-index: -1 will disappear behind the page background or some distant parent.

    However, within our systematic approach, negative values are a powerful tool for internal decorations. When a component creates its own Stacking Context, the z-index is confined to that component. And z-index: var(--z-bottom) simply means “place this behind the default content of this specific container”.

    This is perfect for:

    • Component backgrounds: Subtle patterns or gradients that shouldn’t interfere with text.
    • Shadow simulations: When you need more control than box-shadow provides.
    • Inner glows or borders: Elements that should sit “under” the main UI.

    Conclusion: The z-index Manifesto

    With just a few CSS variables, we’ve built a complete management system for z-index. It’s a simple yet powerful way to ensure that managing layers never feels like a guessing game again.

    To maintain a clean and scalable codebase, here are the golden rules for working with z-index:

    1. No magic numbers: Never use arbitrary values like 999 or 10001. If a number isn’t tied to a system, it’s a bug waiting to happen.
    2. Tokens are mandatory: Every z-index in your CSS should come from a token, either a global layer token or a local positioning token.
    3. It’s rarely the value: If an element isn’t appearing on top despite a “high” value, the problem is almost certainly its Stacking Context, not the number itself.
    4. Think in layers: Stop asking “how high should this be?” and start asking “which layer does this belong to?”
    5. Calc for connection: Use calc() to bind related elements together (like an overlay and its background) rather than giving them separate, unrelated tokens.
    6. Local contexts for local problems: Use local tokens (--z-top, --z-bottom) and internal stacking contexts to manage complexity within components.

    By following these rules, you turn z-index from a chaotic source of bugs into a predictable, manageable part of your design system. The value of z-index isn’t in how high the number is, but in the system that defines it.

    Bonus: Enforcing a Clean System

    A system is only as good as its enforcement. In a deadline-driven environment, it’s easy for a developer to slip in a quick z-index: 999 to “make it just work”. Without automation, your beautiful token system will eventually erode back into chaos.

    To prevent this, I developed a library specifically designed to enforce this exact system: z-index-token-enforcer.

    npm install z-index-token-enforcer --save-dev

    It provides a unified set of tools to automatically flag any literal z-index values and require developers to use your predefined tokens:

    • Stylelint plugin: For standard CSS/SCSS enforcement
    • ESLint plugin: To catch literal values in CSS-in-JS and React inline styles
    • CLI scanner: A standalone script that can quickly scan files directly or be integrated into your CI/CD pipelines

    By using these tools, you turn the “Golden Rules” from a recommendation into a hard requirement, ensuring that your codebase stays clean, scalable, and, most importantly, predictable.


    The Value of z-index originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • The Different Ways to Select <html> in CSS

    Temani Afif recently did this exercise and I thought I’d build off of it. Some of these are useful. Many of them are not. There’s a bird at the end!

    html

    html {
      /* I mean, duh */
    }

    :root

    :root {
      /* Sarsaparilla, anyone? */
    }
    :root is a CSS pseudo-class that matches the root element of the current (XML) document. If the current document is a HTML document, then it matches <html>. The XML documents that you’ll most likely encounter as a web developer (besides HTML) are:
    • SVG documents: :root matches <svg>
    • RSS documents: :root matches <rss>
    • Atom documents: :root matches <feed>
    • MathML documents: :root matches <math>
    • Other XML documents: :root matches the outermost element (e.g., <note>)
    But what’s the practicality of :root? Well, the specificity of pseudo-classes (0-1-0) is higher than that of elements (0-0-1), so you’re less likely to run into conflicts with :root. It’s conventional to declare global custom properties on :root, but I actually prefer :scope because it semantically matches the global scope. In practice though, it makes no difference.
    /* Global variables */
    :root { --color: black; }
    :scope { --color: black; }
    Let’s talk about :scope some more…

    :scope or &

    :scope {
      /* Insert scope creep here */
    }
    Okay, that’s not really what :scope is for. As I mentioned, :scope matches the global scope root (<html>). However, this is only true when not used within the newly baseline @scope at-rule, which is used to define a custom scope root. We can also do this:
    & {
      /* And...? */
    }
    Normally, the & selector is used with CSS nesting to concatenate the current selector to the containing selector, enabling us to nest selectors even when we aren’t technically dealing with nested selectors. For example:
    element:hover {
      /* This */
    }
    
    element {
      &:hover {
        /* Becomes this (notice the &) */
      }
    }
    
    element {
      :hover {
        /* Because this (with no &) */
      }
    }
    
    element :hover {
      /* Means this (notice the space before :hover) */
    }
    
    element {
      :hover & {
        /* Means :hover element, but I digress */
      }
    }
    When & isn’t nested, it simply selects the scope root, which outside of an @scope block is <html>. Who knew?

    ‌:has(head) or :has(body)

    :has(head) {
      /* Nice! */
    }
    
    :has(body) {
      /* Even better! */
    }
    <html> elements should only contain a <head> and <body> (à la Anakin Skywalker) as direct children. Any other markup inserted here is invalid, although parsers will typically move it into the <head> or <body> anyway. More importantly, no other element is allowed to contain <head> or <body>, so when we say :has(head) or :has(body), this can only refer to the <html> element, unless you mistakenly insert <head> or <body> inside of <head> or <body>. But why would you? That’s just nasty. Is :has(head) or :has(body) practical? No. But I am going to plug :has(), and you also learned about the illegal things that you shouldn’t do to HTML bodies.

    :not(* *)

    :not(* *) {
      /* (* *) are my starry eyes looking at CSS <3 */
    }
    Any element that’s contained by another element (* *)? Yeah, :not() that. The only element that’s not contained by another element is the <html> element. *, by the way, is called the universal selector. And if you throw a child combinator right in the middle of them, you get a cute bird:
    :not(* > *) {
      /* Chirp, chirp */
    }
    “Siri, file this under Completely Useless.” (Ironically, Siri did no such thing).
    The Different Ways to Select <html> in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
  • Popover API or Dialog API: Which to Choose?

    Choosing between Popover API and Dialog API is difficult because they seem to do the same job, but they don’t!

    After a bit lots of research, I discovered that the Popover API and Dialog API are wildly different in terms of accessibility. So, if you’re trying to decide whether to use Popover API or Dialog’s API, I recommend you:

    • Use Popover API for most popovers.
    • Use Dialog’s API only for modal dialogs.

    Popovers vs. Dialogs

    The relationship between Popovers and Dialogs are confusing to most developers, but it’s actually quite simple.

    Dialogs are simply subsets of popovers. And modal dialogs are subsets of dialogs. Read this article if you want to understand the rationale behind this relationship.

    ![[popover-accessible-roles.jpg.webp]]

    This is why you could use the Popover API even on a <dialog> element.

    <!-- Using popover on a dialog element --> 
    <dialog popover>...</div> 

    Stylistically, the difference between popovers and modals are even clearer:

    • Modals should show a backdrop.
    • Popovers should not.

    Therefore, you should never style a popover’s ::backdrop element. Doing so will simply indicate that the popover is a dialog — which creates a whole can of problems.

    You should only style a modal’s ::backdrop element.

    Popover API and its accessibility

    Building a popover with the Popover API is relatively easy. You specify three things:

    • a popovertarget attribute on the popover trigger,
    • an id on the popover, and
    • a popover attribute on the popover.

    The popovertarget must match the id.

    <button popovertarget="the-popover"> ... </button>
    <dialog popover id="the-popover"> The Popover Content </dialog>

    Notice that I’m using the <dialog> element to create a dialog role. This is optional, but recommended. I do this because dialog is a great default role since most popovers are simply just dialogs.

    This two lines of code comes with a ton of accessibility features already built-in for you:

    • Automatic focus management
      • Focus goes to the popover when opening.
      • Focus goes back to the trigger when closing.
    • Automatic aria connection
      • No need to write aria-expanded, aria-popup and aria-controls. Browsers handle those natively. Woo!
    • Automatic light dismiss
      • Popover closes when user clicks outside.
      • Popover closes when they press the Esc key.

    Now, without additional styling, the popover looks kinda meh. Styling is a whole ‘nother issue, so we’ll tackle that in a future article. Geoff has a few notes you can review in the meantime.

    Dialog API and its accessibility

    Unlike the Popover API, the Dialog API doesn’t have many built-in features by default:

    • No automatic focus management
    • No automatic ARIA connection
    • No automatic light dismiss

    So, we have to build them ourselves with JavaScript. This is why the Popover API is superior to the Dialog API in almost every aspect — except for one: when modals are involved.

    The Dialog API has a showModal method. When showModal is used, the Dialog API creates a modal. It:

    1. automatically inerts other elements,
    2. prevents users from tabbing into other elements, and
    3. prevents screen readers from reaching other elements.

    It does this so effectively, we no longer need to trap focus within the modal.

    But we gotta take care of the focus and ARIA stuff when we use the Dialog API, so let’s tackle the bare minimum code you need for a functioning dialog.

    We’ll begin by building the HTML scaffold:

    <button 
      class="modal-invoker" 
      data-target="the-modal" 
      aria-haspopup="dialog"
    >...</button>
    
    <dialog id="the-modal">The Popover Content</dialog>

    Notice I did not add any aria-expanded in the HTML. I do this for a variety of reasons:

    1. This reduces the complexity of the HTML.
    2. We can write aria-expanded, aria-controls, and the focus stuff directly in JavaScript – since these won’t work without JavaScript.
    3. Doing so makes this HTML very reusable.

    Setting up

    I’m going to write about a vanilla JavaScript implementation here. If you’re using a framework, like React or Svelte, you will have to make a couple of changes — but I hope that it’s gonna be straightforward for you.

    First thing to do is to loop through all dialog-invokers and set aria-expanded to false. This creates the initial state.

    We will also set aria-controls to the <dialog> element. We’ll do this even though aria-controls is poop, ’cause there’s no better way to connect these elements (and there’s no harm connecting them) as far as I know.

    const modalInvokers = Array.from(document.querySelectorAll('.modal-invoker'))
    
    modalInvokers.forEach(invoker => {
      const dialogId = invoker.dataset.target
      const dialog = document.querySelector(`#${dialogId}`)
      invoker.setAttribute('aria-expanded', false)
      invoker.setAttribute('aria-controls', dialogId)
    })

    Opening the modal

    When the invoker/trigger is clicked, we gotta:

    1. change the aria-expanded from false to true to show the modal to assistive tech users, and
    2. use the showModal function to open the modal.

    We don’t have to write any code to hide the modal in this click handler because users will never get to click on the invoker when the dialog is opened.

    modalInvokers.forEach(invoker => {
      // ... 
    
      // Opens the modal
      invoker.addEventListener('click', event => {
        invoker.setAttribute('aria-expanded', true)
        dialog.showModal()
      })
    })

    Great. The modal is open. Now we gotta write code to close the modal.

    Closing the modal

    By default, showModal doesn’t have automatic light dismiss, so users can’t close the modal by clicking on the overlay, or by hitting the Esc key. This means we have to add another button that closes the modal. This must be placed within the modal content.

    <dialog id="the-modal"> 
      <button class="modal-closer">X</button>
      <!-- Other modal content -->
    </dialog>

    When users click the close button, we have to:

    1. set aria-expanded on the opening invoker to false,
    2. close the modal with the close method, and
    3. bring focus back to the opening invoker element.
    modalInvokers.forEach(invoker => {
      // ... 
    
      // Opens the modal
      invoker.addEventListener('click', event => {
        invoker.setAttribute('aria-expanded', true)
        dialog.showModal()
      })
    })
    
    const modalClosers = Array.from(document.querySelectorAll('.modal-closer'))
    
    modalClosers.forEach(closer => {
      const dialog = closer.closest('dialog')
      const dialogId = dialog.id
      const invoker = document.querySelector(`[data-target="${dialogId}"]`)
      
      closer.addEventListener('click', event => {
        dialog.close()
        invoker.setAttribute('aria-expanded', false)
        invoker.focus()
      })
    })

    Phew, with this, we’re done with the basic implementation.

    Of course, there’s advanced work like light dismiss and styling… which we can tackle in a future article.

    Can you use the Popover API to create modals?

    Yeah, you can.

    But you will have to handle these on your own:

    1. Inerting other elements
    2. Trapping focus

    I think what we did earlier (setting aria-expanded, aria-controls, and focus) are easier compared to inerting elements and trapping focus.

    The Dialog API might become much easier to use in the future

    A proposal about invoker commands has been created so that the Dialog API can include popovertarget like the Popover API.

    This is on the way, so we might be able to make modals even simpler with the Dialog API in the future. In the meantime, we gotta do the necessary work to patch accessibility stuff.

    Deep dive into building workable popovers and modals

    We’ve only began to scratch the surface of building working popovers and modals with the code above — they’re barebone versions that are accessible, but they definitely don’t look nice and can’t be used for professional purposes yet.

    To make the process of building popovers and modals easier, we will dive deeper into the implementation details for a professional-grade popover and a professional-grade modal in future articles.

    In the meantime, I hope these give you some ideas on when to choose the Popover API and the Dialog API!

    Remember, there’s no need to use both. One will do.


    Popover API or Dialog API: Which to Choose? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • What’s !important #6: :heading, border-shape, Truncating Text From the Middle, and More

    Despite what’s been a sleepy couple of weeks for new Web Platform Features, we have an issue of What’s !important that’s prrrretty jam-packed. The web community had a lot to say, it seems, so fasten your seatbelts!

    @keyframes animations can be strings

    Peter Kröner shared an interesting fact about @keyframes animations — that they can be strings:

    @keyframes "@animation" {
      /* ... */
    }
    
    #animate-this {
      animation: "@animation";
    }

    Yo dawg, time for a #CSS fun fact: keyframe names can be strings. Why? Well, in case you want your keyframes to be named “@keyframes,” obviously! #webdev

    [image or embed]

    — Peter Kröner (@sirpepe.bsky.social) Feb 18, 2026 at 10:33

    I don’t know why you’d want to do that, but it’s certainly an interesting thing to learn about @keyframes after 11 years of cross-browser support!

    : vs. = in style queries

    Another hidden trick, this one from Temani Afif, has revealed that we can replace the colon in a style query with an equals symbol. Temani does a great job at explaining the difference, but here’s a quick code snippet to sum it up:

    .Jay-Z {
      --Problems: calc(98 + 1);
    
      /* Evaluates as calc(98 + 1), color is blueivy */
      color: if(style(--Problems: 99): red; else: blueivy);
    
      /* Evaluates as 99, color is red */
      color: if(style(--Problems = 99): red; else: blueivy);
    }

    In short, = evaluates --Problems differently to :, even though Jay-Z undoubtably has 99 of them (he said so himself).

    Declarative <dialog>s (and an updated .visually-hidden)

    David Bushell demonstrated how to create <dialog>s declaratively using invoker commands, a useful feature that allows us to skip some J’Script in favor of HTML, and works in all web browsers as of recently.

    Also, thanks to an inquisitive question from Ana Tudor, the article spawned a spin-off about the minimum number of styles needed for a visually-hidden utility class. Is it still seven?

    Maybe not…

    How to truncate text from the middle

    Wes Bos shared a clever trick for truncating text from the middle using only CSS:

    Someone on reddit posted a demo where CSS truncates text from the middle. They didn't post the code, so here is my shot at it with Flexbox

    [image or embed]

    — Wes Bos (@wesbos.com) Feb 9, 2026 at 17:31

    Donnie D’Amato attempted a more-native solution using ::highlight(), but ::highlight() has some limitations, unfortunately. As Henry Wilkinson mentioned, Hazel Bachrach’s 2019 call for a native solution is still an open ticket, so fingers crossed!

    How to manage color variables with relative color syntax

    Theo Soti demonstrated how to manage color variables with relative color syntax. While not a new feature or concept, it’s frankly the best and most comprehensive walkthrough I’ve ever read that addresses these complexities.

    How to customize lists (the modern way)

    In a similar article for Piccalilli, Richard Rutter comprehensively showed us how to customize lists, although this one has some nuggets of what I can only assume is modern CSS. What’s symbols()? What’s @counter-style and extends? Richard walks you through everything.

    A table with headings titled CSS and USE CASE detailing HTML list customizations. It lists the property list-style for basic bullet styles; the pseudo-element li::marker for coloring numbering; the function symbols() for Firefox-specific styles; the at-rule @counter-style for custom numbering systems; the descriptor extends for modifying existing systems; and the pseudo-element li::before for advanced marker positioning.
    Source: Piccalilli.

    Can’t get enough on counters? Juan Diego put together a comprehensive guide right here on CSS-Tricks.

    How to create typescales using :heading

    Safari Technology Preview 237 recently began trialing :heading/:heading(), as Stuart Robson explains. The follow-up is even better though, as it shows us how pow() can be used to write cleaner typescale logic, although I ultimately settled on the old-school <h1><h6> elements with a simpler implementation of :heading and no sibling-index():

    :root {
      --font-size-base: 16px;
      --font-size-scale: 1.5;
    }
    
    :heading {
      /* Other heading styles */
    }
    
    /* Assuming only base/h3/h2/h1 */
    
    body {
      font-size: var(--font-size-base);
    }
    
    h3 {
      font-size: calc(var(--font-size-base) * var(--font-size-scale));
    }
    
    h2 {
      font-size: calc(var(--font-size-base) * pow(var(--font-size-scale), 2));
    }
    
    h1 {
      font-size: calc(var(--font-size-base) * pow(var(--font-size-scale), 3));
    }

    Una Kravets introduced border-shape

    Speaking of new features, border-shape came as a surprise to me considering that we already have — or will have — corner-shape. However, border-shape is different, as Una explains. It addresses the issues with borders (because it is the border), allows for more shapes and even the shape() function, and overall it works differently behind the scenes.

    Source: Una Kravets.

    modern.css wants you to stop writing CSS like it’s 2015

    It’s time to start using all of that modern CSS, and that’s exactly what modern.css wants to help you do. All of those awesome features that weren’t supported when you first read about them, that you forgot about? Or the ones that you missed or skipped completely? Well, modern.css has 75 code snippets and counting, and all you have to do is copy ‘em.

    Screenshot of a website titled modern.css showing browser compatibility filters and six code snippets, labeled with their category (e.g., SELECTORS or LAYOUT), difficulty level, topic, an example of outdated code to avoid, a browser support percentage, and a link to view the modern solution.

    Kevin Powell also has some CSS snippets for you

    And the commenters? They have some too!

    Honestly, Kevin is the only web dev talker that I actually follow on YouTube, and he’s so close to a million followers right now, so make sure to hit ‘ol K-Po’s “Subscribe” button.

    In case you missed it

    Actually, you didn’t miss that much! Firefox 148 released the shape() function, which was being held captive by a flag, but is now a baseline feature. Safari Technology Preview 237 became the first to trial :heading. Those are all we’ve seen from our beloved browsers in the last couple of weeks (not counting the usual flurry of smaller updates, of course).

    That being said, Chrome, Safari, and Firefox announced their targets for Interop 2026, revealing which Web Platform Features they intend to make consistent across all web browsers this year, which more than makes up for the lack of shiny features this week.

    Also coming up (but testable in Chrome Canary now, just like border-shape) is the scrolled keyword for scroll-state container queries. Bramus talks about scrolled scroll-state queries here.

    Remember, if you don’t want to miss anything, you can catch these Quick Hits as the news breaks in the sidebar of css-tricks.com.

    See you in a fortnight!


    What’s !important #6: :heading, border-shape, Truncating Text From the Middle, and More originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • Yet Another Way to Center an (Absolute) Element

    TL;DR: We can center absolute-positioned elements in three lines of CSS. And it works on all browsers!

    .element {
      position: absolute;
      place-self: center; 
      inset: 0;
    }

    Why? Well, that needs a longer answer.

    In recent years, CSS has brought a lot of new features that don’t necessarily allow us to do new stuff, but certainly make them easier and simpler. For example, we don’t have to hardcode indexes anymore:

    <ul style="--t: 8">
      <li style="--i: 1"></li>
      <li style="--i: 2"></li>
      <!--  ...  -->
      <li style="--i: 8"></li>
    </ul>

    Instead, all this is condensed into the sibling-index() and sibling-count() functions. There are lots of recent examples like this.

    Still, there is one little task that feels like we’ve doing the same for decades: centering an absolutely positioned element, which we usually achieve like this:

    .element {
      position: absolute;
      top: 50%;
      left: 50%;
      
      translate: -50% -50%;
    }

    We move the element’s top-left corner to the center, then translate it back by 50% so it’s centered.

    There is nothing wrong with this way — we’ve been doing it for decades. But still it feels like the old way. Is it the only way? Well, there is another not-so-known cross-browser way to not only center, but also easily place any absolutely-positioned element. And what’s best, it reuses the familiar align-self and justify-self properties.

    Turns out that these properties (along with their place-self shorthand) now work on absolutely-positioned elements. However, if we try to use them as is, we’ll notice our element doesn’t even flinch.

    /* Doesn't work!! */
    .element {
      position: absolute;
      place-self: center; 
    }

    So, how do align-self and justify-self work for absolute elements? It may be obvious to say they should align the element, and that’s true, but specifically, they align it within its Inset-Modified Containing Block (IMCB). Okay… But what’s the IMCB?

    Imagine we set our absolute element width and height to 100%. Even if the element’s position is absolute, it certainly doesn’t grow infinitely, but rather it’s enclosed by what’s known as the containing block.

    The containing block is the closest ancestor with a new stacking context. By default, it is the html element.

    We can modify that containing block using inset properties (specifically top, right, bottom, and left). I used to think that inset properties fixed the element’s corners (I even said it a couple of seconds ago), but under the hood, we are actually fixing the IMCB borders.

    Diagram showing the CSS for an absolutely-positioning element with inset properties and how those values map to an element.

    By default, the IMCB is the same size as the element’s dimensions. So before, align-self and justify-self were trying to center the element within itself, resulting in nothing. Then, our last step is to set the IMCB so that it is the same as the containing block.

    .element {
      position: absolute;
      place-self: center; 
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
    }

    Or, using their inset shorthand:

    .element {
      position: absolute;
      place-self: center; 
      inset: 0;
    }

    Only three lines! A win for CSS nerds. Admittedly, I might be cheating since, in the old way, we could also use the inset property and reduce it to three lines, but… let’s ignore that fact for now.

    We aren’t limited to just centering elements, since all the other align-self and justify-self positions work just fine. This offers a more idiomatic way to position absolute elements.

    Pro tip: If we want to leave a space between the absolutely-positioned element and its containing block, we could either add a margin to the element or set the container’s inset to the desired spacing.

    What’s best, I checked Caniuse, and while initially Safari didn’t seem to support it, upon testing, it seems to work on all browsers!


    Yet Another Way to Center an (Absolute) Element originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • An Exploit … in CSS?!

    Ok, take a deep breath.

    We’ll have some fun understanding this vulnerability once you make sure your browser isn’t affected, using the table below.

    Chromium-based browserAm I safe?
    Google ChromeEnsure you’re running version 145.0.7632.75 or later. Go to Settings > About Chrome and check for updates.
    Microsoft EdgeEnsure you’re running on version 145.0.3800.58 or later. Click on the three dots (…) on the very right-hand side of the window. Click on Help and Feedback > About Microsoft Edge.
    VivaldiEnsure you’re running on version 7.8 or later. Click the V icon (menu) in the top-left corner, select Help > About.
    BraveEnsure you’re running on version v1.87.188 or later. Click the hamburger menu on the top right, select Help > About Brave.

    So, you updated your browser and said a prayer. When you’re able to string whole sentences together again, your first question is: Has CSS really had the dubious honor of being the cause of the first zero-day exploit in Chromium-based browsers for 2026?

    I mean, the Chrome update channel says they fixed a high-severity vulnerability described as “[u]ser after free in CSS” … on Friday the 13th no less! If you can’t trust a release with a description and date like that, what can you trust? Google credits security researcher Shaheen Fazim with reporting the exploit to Google. The dude’s LinkedIn says he’s a professional bug hunter, and I’d say he deserves the highest possible bug bounty for finding something that a government agency is saying “in CSS in Google Chrome before 145.0.7632.75 allowed a remote attacker to execute arbitrary code inside a sandbox via a crafted HTML page.”

    Is this really a CSS exploit?

    Something doesn’t add up. Even this security researcher swears by using CSS instead of JavaScript, so her security-minded readers don’t need to enable JavaScript when they read her blog. She trusts the security of CSS, even though she understands it enough to create a pure CSS x86 emulator (sidenote: woah). So far, most of us have taken for granted that the possible security issues in CSS are relatively tame. Surely we don’t suddenly live in a world where CSS can hijack someone’s OS, right?

    Well, in my opinion, the headlines describing the bug as a CSS exploit in Chrome are a bit clickbait-y, because they make it sound like a pure CSS exploit, as though malicious CSS and HTML would be enough to perform it. If I’m being honest, when I first skimmed those articles in the morning before rushing out to catch the train to work, the way the articles were worded made me imagine malicious CSS like:

    .malicious-class {
      vulnerable-property: 'rm -rf *';
    }

    In the fictional, nightmare version of the bug that my malinformed imagination had conjured, some such CSS could be “crafted” to inject that shell command somewhere it would run on the victim’s machine. Even re-reading the reports more carefully, they feel intentionally misleading, and it wasn’t just me. My security-minded friend’s first question to me was, “But… isn’t CSS, like, super validatable?” And then I dug deeper and found out the CSS in the proof of concept for the exploit isn’t the malicious bit, which is why CSS validation wouldn’t have helped!

    It doesn’t help the misunderstanding when the SitePoint article about CVE-2026-2441 bizarrely lies to its readers about what this exploit is, instead describing a different medium-severity bug that allows sending the rendered value of an input field to a malicious server by loading images in CSS. That is not what this vulnerability is.

    It’s not really a CSS exploit in the sense that JavaScript is the part that exploits the bug. I’ll concede that the line of code that creates the condition necessary for a malicious script to perform this attack was in Google Chrome’s Blink CSS engine component, but the CSS involved isn’t the malicious part.

    So, how did the exploit work?

    The CSS involvement in the exploit lies in the way Chrome’s rendering engine turns certain CSS into a CSS object model. Consider the CSS below:

    @font-feature-values VulnTestFont {
      @styleset {
        entry_a: 1;
        entry_b: 2;
        entry_c: 3;
        entry_d: 4;
        entry_e: 5;
        entry_f: 6;
        entry_g: 7;
        entry_h: 8;
      }
    }

    When this CSS is parsed, a CSSFontFeaturesValueMap is added to the collection of CSSRule objects in the document.styleSheets[0].cssRules. There was a bug in the way Chrome managed the memory for the HashMap data structure underlying the JavaScript representation of the CSSFontFeaturesValueMap, which inadvertently allowed a malicious script to access memory it shouldn’t be able to. This by itself isn’t sufficient to cause harm other than crashing the browser, but it can form the basis for a Use After Free (UAF) exploit.

    Chrome’s description of the patch mentions that “Google is aware that an exploit for CVE-2026-2441 exists in the wild,” although for obvious reasons, they are coy about the details for a full end-to-end exploit. Worryingly, @font-feature-values isn’t new — it’s been available since early 2023 — but the discovery of an end-to-end UAF exploit may be recent. It would make sense if the code that created the possibility of this exploit is old, but someone only pulled off a working exploit recently. If you look at this detailed explanation of a 2020 Use After Free vulnerability in Chrome within the WebAudio API, you get the sense that accessing freed memory is only one piece of the puzzle to get a UAF exploit working. Modern operating systems create hoops that attackers have to go through, which can make this kind of attack quite hard.

    Real-world examples of this kind of vulnerability get complex, especially in a Chrome vulnerability where you can only trigger low-level statements indirectly. But if you know C and want to understand the basic principles with a simplified example, you can try this coding challenge. Another way to help understand the ideas is this medium post about the recent Chrome CSSFontFeaturesValueMap exploit, which includes a cute analogy in which the pointer to the object is like a leash you are still holding even after you freed your dog — but an attacker hooks the leash to a cat instead (known as type confusion), so when you command your “dog” to bark, the attacker taught his cat to think that “bark” command means to do something malicious instead.

    The world is safe again, but for how long?

    The one-line fix I mentioned Chrome made was to change the Blink code to work with a deep copy of the HashMap that underlies the CSSFontFeaturesValueMap rather than a pointer to it, so there is no possibility of referencing freed memory. By contrast, it seems Firefox rewrote its CSS renderer in Rust and therefore tends to handle memory management automatically. Chromium started to support the use of Rust since 2023. One of the motivations mentioned was “safer (less complex C++ overall, no memory safety bugs in a sandbox either)” and to “improve the security (increasing the number of lines of code without memory safety bugs, decreasing the bug density of code) of Chrome.” Since it seems the UAF class of exploit has recurred in Chromium over the years, and these vulnerabilities tend to be high-severity when discovered, a more holistic approach to defending against such vulnerabilities might be needed, so I don’t have to freak you out with another article like this.


    An Exploit … in CSS?! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • A Complete Guide to Bookmarklets

    You’re surely no stranger to bookmarks. The ability to favorite, save, or “bookmark” web pages has been a staple browser feature for decades. Browsers don’t just let you bookmark web pages, though. You can also bookmark JavaScript, allowing you to do so much more than merely save pages.

    A JavaScript script saved as a bookmark is called a “bookmarklet,” although some people also use the term “favelet” or “favlet.” Bookmarklets have been around since the late 90s. The site that coined them, bookmarklets.com, even remains around today. They’re simple and versatile, a fact evidenced by most of the bookmarklets listed on the aforementioned site are still working today despite being untouched for over two decades.

    While bookmarklets have fallen a bit to the wayside in more recent years as browsers have grown more capable and dev tools have matured, they’re still a valuable tool in any web developer’s arsenal. They’re simple but capable, and no additional software is needed to create or use them. If you watch any good machinist or engineer at work, they’re constantly building tools and utilities, even one-off contraptions, to address problems or come to a more graceful solution as they work. As developers, we should endeavor to do the same, and bookmarklets are a perfect way to facilitate such a thing.

    Making a Bookmarklet

    Bookmarklets are extremely easy to make. You write a script in exactly the same manner you would if writing it for the browser console. You then save it as a bookmark, prefixing it with javascript: which designates it for use in the browser URL bar.

    Let’s work through making a super basic bookmarklet, one that sends a simple alert. We’ll take the below code, which triggers a message using the alert() method, and bookmarklet-ify it.

    alert("Hello, World!");

    Next, we will turn it into an Immediately Invoked Function Expression (IIFE), which has a few benefits. Firstly, it creates a new scope to avoid polluting the global namespace and prevents our bookmarklet from interfering with JavaScript already on the page, or vice versa. Secondly, it will cause the bookmarklet to trigger upon click.

    We’ll achieve this by enclosing it within an anonymous function (lambda) (e.g., (() => {})) and suffixing it with ();, which will execute our function.

    (() => {
      alert("Hello, World!");
    })();

    For reliability across browsers, it is to our benefit to URL-encode our bookmarklet to escape special characters. Without doing so, browsers can go awry and misinterpret our code. Even if it isn’t entirely necessary with a simple bookmarklet like this, it can prevent a lot of trouble that may arise with more complexity. You can encode your bookmarklet yourself using JavaScript’s encodeURIComponent() function, or you can use one of a number of existing tools. We’ll also reduce it to a single line.

    (()%3D%3E%7Balert(%22Hello%2C%20World!%22)%3B%7D)()%3B

    We must prefix javascript: so that our browser knows this is not a standard URL to a webpage but instead a JavaScript bookmarklet.

    javascript:(()%3D%3E%7Balert(%22Hello%2C%20World!%22)%3B%7D)()%3B

    Installing a Bookmarklet

    Finally, we must add it to our browser as a bookmarklet. As you might expect, this is extremely dependent on the browser you’re using.

    In Safari on macOS, the easiest way is to bookmark a webpage and then edit that bookmark into a bookmarklet:

    Safari window with the Favorites tab opened and a context menu open for an item highlighting the Edit Address option.

    In Firefox on desktop, the easiest way is to secondary click on the bookmark toolbar and then “Add Bookmark…”:

    Firefox window showing the Add Bookmark option.

    In Chrome on desktop, the easiest way is to secondary click on the bookmark toolbar and then “Add page…”:

    Chrome window showing the Add page option.

    Many mobile browsers also allow the creation and usage of bookmarks. This can be especially valuable, as browser dev tools are often unavailable on mobile.

    CSS Bookmarklets

    You’ve no doubt been looking at the word “JavaScript” above with a look of disdain. This is CSS-Tricks after all. Fear not, because we can make bookmarklets that apply CSS to our page in a plethora of ways.

    My personal favorite method from an authoring perspective is to create a <style> element with my chosen content:

    javascript: (() => {
      var style = document.createElement("style");
      style.innerHTML = "body{background:#000;color:rebeccapurple}";
      document.head.appendChild(style);
    })();

    The much more graceful approach is to use the CSSStyleSheet interface. This approach allows for incremental updates and lets you directly access the CSS Object Model (CSSOM) to read selectors, modify existing properties, remove or reorder rules, and inspect computed structure. The browser also validates values input this way, which helps prevent you from inputting broken CSS. It is more complex but also gives you greater control.

    javascript: (() => {
      const sheet = new CSSStyleSheet();
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
      sheet.insertRule("body { border: 5px solid rebeccapurple !important; }", 0);
      sheet.insertRule("img { filter: contrast(10); }", 1);
    })();

    As we’re writing CSS for general usage across whatever page we wish to use our bookmarklet on, it is important to remain aware that we may run into issues with specificity or conflicts with the page’s existing stylesheets. Using !important is usually considered a bad code smell, but in the context of overriding unknown existing styles, it is a reasonable way to address our needs.

    Limitations

    Unfortunately, there are a few roadblocks that can hinder our usage of bookmarklets. The most pervasive are Content Security Policies (CSP). A CSP is a security feature that attempts to prevent malicious actions, such as cross-site scripting attacks, by allowing websites to regulate what can be loaded. You wouldn’t want to allow scripts to run on your bank’s website, for instance. A bookmarklet that relies on cross-origin requests (requests from outside the current website) is very frequently blocked. For this reason, a bookmarklet should ideally be self-contained, rather than reliant on anything external. If you’re suspicious a bookmarklet is being blocked by a website’s security policies, you can check the console in your browser’s developer tools for an error.

    Firefox blocking a bookmarklet from running due to inline scripts being disallowed.

    As bookmarklets are just URLs, there isn’t any strict limit to the length specified. In usage, browsers do impose limits, though they’re higher than you’ll encounter in most cases. In my own testing (which may vary by version and platform), here are the upper limits I found: The largest bookmarklet I could create in both Firefox and Safari was 65536 bytes. Firefox wouldn’t let me create a bookmarklet of any greater length, and Safari would let me create a bookmarklet, but it would do nothing when triggered. The largest bookmarklet I could create in Chrome was 9999999 characters long, and I started having issues interacting with the textbox after that point. If you need something longer, you might consider loading a script from an external location, keeping in mind the aforementioned CSP limitations:

    javascript:(() => {
      var script=document.createElement('script');
      script.src='https://example.com/bookmarklet-script.js';
      document.body.appendChild(script);
    })();

    Otherwise, you might consider a userscript tool like TamperMonkey, or, for something more advanced, creating your own browser extension. Another option is creating a snippet in your browser developer tools. Bookmarklets are best for small snippets.

    Cool Bookmarklets

    Now that you’ve got a gauge on what bookmarklets are and, to an extent, what they’re capable of, we can take a look at some useful ones. However, before we do, I wish to stress that you should be careful running bookmarklets you find online. Bookmarklets you find online are code written by someone else. As always, you should be wary, cautious, and discerning. People can and have written malicious bookmarklets that steal account credentials or worse.

    For this reason, if you paste code starting with javascript: into the address bar, browsers automatically strip the javascript: prefix to prevent people from unwittingly triggering bookmarklets. You’ll need to reintroduce the prefix. To get around the javascript: stripping, bookmarklets are often distributed as links on a page, which you’re expected to drag and drop into your bookmarks.

    Specific bookmarklets have been talked about on CSS-Tricks before. Given the evolution of browsers and the web platform, much has been obsoleted now, but some more contemporary articles include:

    Be sure to check out the comments of those posts, for they’re packed with countless great bookmarklets from the community. Speaking of bookmarklets from the community:

    If you’ve got any golden bookmarklets that you find valuable, be sure to share them in the comments.


    A Complete Guide to Bookmarklets originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

  • The AI Infrastructure Tax: Unseen Costs Draining Your Cloud Spend

    Enterprise websites today are typically built for human users. They are often dynamic, JavaScript-driven, and highly personalized.

  • Loading Smarter: SVG vs. Raster Loaders in Modern Web Design

    I got this interesting question in an SVG workshop: “What is the performance difference between an SVG loader and simply rotating an image for a loader?”

    The choice between Scalable Vector Graphics (SVG) and raster image loaders involves many factors like performance, aesthetics, and user experience. The short answer to that question is: there’s almost no difference at all if you are working on something very small and specific. But let’s get more nuanced in this article and discuss the capabilities of both formats so that you can make informed decisions in your own work.

    Understanding the formats

    SVGs are vector-based graphics, popular for their scalability and crispness. But let’s start by defining what raster images and vector graphics actually are.

    Raster images are based on physical pixels. They contain explicit color information for every single pixel. What happens is that you send the entire pixel-by-pixel information, and the browser paints each pixel one by one, making the network work harder.

    This means:

    • they have a fixed resolution (scaling can introduce blurriness),
    • the browser must decode and paint each frame, and
    • animation usually means frame-by-frame playback, like GIFs or video loops.

    Vectors are mathematical instructions that tell the computer how to draw a graphic. As Chris Coyier said in this CSS Conf: “Why send pixels when you can send math?” So, instead of sending the pixels with all the information, SVG sends instructions for how to draw the thing. In other words, let the browser do more and the network do less.

    Because of this, SVGs:

    • scale infinitely without losing quality,
    • can be styled and manipulated with CSS and JavaScript, and
    • can live directly in the DOM, eliminating that extra HTTP request.
    Comparing two circular shapes, in SVG on the left, and raster on the right. The vector is clear and sharp while the raster is pixelated and does not support transparency.

    The power of vectors: Why SVG wins

    There are several reasons why it’s generally a good idea to go with SVG over raster images.

    1. Transparency and visual quality

    Most modern image formats support transparency, but not all transparency is equal. GIFs, for example, only support binary transparency , which means  pixels are either fully transparent or fully opaque.

    This often results in jagged edges at larger scales, especially around curves or on opaque or transparent backgrounds. SVGs support true transparency and smooth edges, which makes a noticeable difference for loaders that sit on top of complex UI layers.

    JPGGIFPNGSVG
    Vector
    Raster
    Transparency
    Animation
    CompressionLossyLosslessLosslessLossless

    2. “Zero request” performance

    From a raw performance perspective, rotating a small PNG and an SVG in CSS (or JavaScript for that matter) is similar. SVGs, however, win in practice because they are gzip-friendly and can be embedded inline.

    <!-- Inline SVG: Heart -->
    <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <title xmlns="">Heart</title>
      <path fill="currentColor" d="M8.4 5.25c-2.78 0-5.15 2.08-5.15 4.78c0 1.863.872 3.431 2.028 4.73c1.153 1.295 2.64 2.382 3.983 3.292l2.319 1.57a.75.75 0 0 0 .84 0l2.319-1.57c1.344-.91 2.83-1.997 3.982-3.292c1.157-1.299 2.029-2.867 2.029-4.73c0-2.7-2.37-4.78-5.15-4.78c-1.434 0-2.695.672-3.6 1.542c-.905-.87-2.167-1.542-3.6-1.542"/>
    </svg>
    
    <!-- Raster image -->
    <img src="/img/heart.png" alt="Solid black heart">

    By pasting the SVG code directly into your HTML, you eliminate an entire HTTP request. For something like a loader — a thing that’s supposed to show up while other things are loading — the fact that SVG code is already there and renders instantly is a huge win for performance.

    More importantly, loaders affect perceived performance. A loader that adapts smoothly to its context and scales correctly can make wait times feel shorter, even if the actual load time is the same.

    And even though the SVG code looks like it would be heavier than a single line of HTML, it’s the image’s file size that truly matters. And the fact that we’re measuring SVG in bytes that can be gzipped means it’s a lot less overhead in the end.

    All that being said, it is still possible to import an SVG in an <img> just like a raster file (among a few other ways as well):

    <img src="/img/heart.svg" alt="Solid black heart">

    And, yes, that does count as a network request even though it respects the vector-ness of the file when it comes to crisp edges at any scale. That, and it eliminates other benefits, like the very next one.

    3. Animation, control, and interactivity

    Loaders formatted in SVG are DOM-based, not frame-based. That means you can:

    You can manipulate your SVGs with CSS, JavaScript, or SMIL, creating a whole world of possibilities when it comes to interactivity that raster images are incapable of matching.

    4. But do I need separate files for an animated SVG?

    Again, SVG animations can live inline in the HTML or inside a single .svg file. This means you can ship one animated file, much like a GIF, but with far more control. By using <defs> and <use>, you can keep the code clean. Here is an example of an SMIL loader file:

    <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100" overflow="visible" fill="#ff5463" stroke="none" role="img" aria-labelledby="loader-title">
      <title id="loader-title">Loading...</title>
      <defs>
        <circle id="loader" r="4" cx="50" cy="50" transform="translate(0 -30)"/>
      </defs>
      <use xlink:href="#loader" transform="rotate(45 50 50)">
        <animate attributeName="opacity" values="0;1;0" dur="1s" begin="0.13s" repeatCount="indefinite"></animate>
      </use>
      <use xlink:href="#loader" transform="rotate(90 50 50)">
        <animate attributeName="opacity" values="0;1;0" dur="1s" begin="0.25s" repeatCount="indefinite"></animate>
      </use>
    </svg>

    For more complex interactions, you can even include CSS and JavaScript inside your SVG file:

    <svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
    <title id="titleId">Interactive Loading Spinner</title> <desc id="descId">A blue rotating circle. Clicking it toggles the rotation speed between fast and slow.</desc>
      <defs>
        <style>
          .loader {
            transform-origin: center;
            animation: spin 1s linear infinite;
            cursor: pointer;
          }
          @keyframes spin {
            to { transform: rotate(360deg); }
          }
        </style>
      </defs>
      
      <circle class="loader" id="loader" cx="50" cy="50" r="35" 
              fill="none" stroke="#3b82f6" stroke-width="6" 
              stroke-dasharray="150" stroke-dashoffset="50" 
              stroke-linecap="round" />
      
      <script type="text/javascript">
        const loader = document.getElementById('loader');
        loader.addEventListener('click', function() {
          this.style.animationDuration = this.style.animationDuration === '0.3s' ? '1s' : '0.3s';
        });
      </script>
    </svg>

    By embedding styles and scripts, you are essentially creating a standalone mini-application inside a single graphic. The primary advantage is encapsulation: the loader is completely portable, requires fewer HTTP requests, and its styles won’t “bleed” into your website. It’s the ultimate “drop-in” asset for different projects.

    However, this power comes with a trade-off in functionality and security. Browsers treat SVGs as static images when loaded via <img> tags or CSS backgrounds, which disables all JavaScript for safety. To keep the interactivity alive, you must either inline the code directly or load the file using an <object> tag. Because of these limitations, the inline method (pasting the code directly into your HTML) remains the preferred choice for most modern web applications.

    5. Creativity, brand, and user experience

    This is where we move beyond performance and into storytelling.

    Imagine a B2B site where a user creates an online store. It takes a few seconds to generate. Instead of a generic spinner, you could show an animation of products “arriving” at the store. You can even make this loader interactive.

    An SVG animation like this can be less than 20kb. To do the same thing with a raster GIF, we would be talking about megabytes. SVG’s efficiency allows you to expand your brand voice and engage users during wait times without killing your performance.

    When raster loaders still make sense

    Raster loaders aren’t “wrong”  per se; they’re just limited in what they can do, especially when compared to SVG. That said, raster images do still make sense when:

    • the loader is photographic or uses complex, illustration-heavy textures,
    • you’re working within legacy systems that don’t allow SVG injection, or
    • you need a very quick, one-off drop-in asset with zero customization needed.

    Summary

    FeatureRaster (GIF/PNG)SVG
    Visual qualityMight be blurry on retina screensCrisp and sharp at any scale
    File sizeTypically larger (KB/MB)Very small (bytes)
    CustomizationRequires re-exportingModify directly with CSS/JavaScript
    Network requestsTypically one HTTP requestZero if inlined directly into HTML

    Final thoughts

    If you’re displaying a loading indicator that’s as simple as a rotating tiny dot, the performance difference between SVG and raster might be negligible. But once you consider scalability, transparency, accessibility, and the ability to tell a brand story, SVG loaders become about more than just performance ;  they’re about building loaders that actually belong to the modern web.

    If you want to experiment with this, I invite you to try loaders.holasvg.com. It’s a free open-source generator I built that lets you customize parameters like animation, shape, and color, and then gives you the clean SVG code to use.


    Loading Smarter: SVG vs. Raster Loaders in Modern Web Design 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.