<?xml version="1.0" encoding="utf-8"?>
  <rss version="2.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
    xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
    xmlns:georss="http://www.georss.org/georss"
    xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"
  >
    <channel>
      <title>Piccalilli - CSS topic archive</title>
      <link>https://piccalil.li/</link>
      <atom:link href="https://piccalil.li/category/css.xml" rel="self" type="application/rss+xml" />
      <description>We are Piccalilli. A publication dedicated to providing high quality educational content to level up your front-end skills.</description>
      <language>en-GB</language>
      <copyright>Piccalilli - CSS topic archive 2026</copyright>
      <docs>https://www.rssboard.org/rss-specification</docs>
      <pubDate>Wed, 15 Jul 2026 19:05:47 GMT</pubDate>
      <lastBuildDate>Wed, 15 Jul 2026 19:05:47 GMT</lastBuildDate>

      
      <item>
        <title>Navigating the age-old problem of checkmarks in UI with progressive enhancement</title>
        <link>https://piccalil.li/blog/navigating-the-age-old-problem-of-checkmarks-in-ui-with-progressive-enhancement/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Sunkanmi Fafowora]]></dc:creator>
        <pubDate>Thu, 28 May 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/navigating-the-age-old-problem-of-checkmarks-in-ui-with-progressive-enhancement/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>The <code>::checkmark</code> pseudo-element was introduced in <a href="https://drafts.csswg.org/css-forms-1/"><strong>CSS Form Control Styling Module Level 1</strong></a> and it’s a powerful CSS feature to say the least. I even wrote about it as an <a href="https://css-tricks.com/almanac/pseudo-selectors/c/checkmark/">almanac entry for CSS-Tricks in 2025</a> to share what this pseudo-element can do.</p>
<p>The CSS <code>::checkmark</code> pseudo-element is used to style the checked state of input elements with checkers including the <code>&lt;select&gt;</code> dropdown, checkboxes, and radio buttons. There’s one problem: at the time of writing, <code>::checkmark</code> lacks browser support on two major browsers: Safari &amp; Firefox.</p>
<p>That’s not all.</p>
<p><a href="https://drafts.csswg.org/css-forms-1/#checkmark">According to the specification</a>, <code>::checkmark</code> is supposed to support styling of checkmarks present in checkboxes, radios, and option elements, but again, at the time of writing, it’s only supported for <code>&lt;option&gt;</code> elements in the <code>&lt;select&gt;</code> dropdown.</p>
<p>This means that <code>::checkmark</code> , as simple, powerful, and useful as it is, is still very limited, and that’s a problem. So, do we rely on the traditional solutions we used to use in the past to style checkmarks? Are they better? In this article, we will be exploring how the problem of customised checkmarks was solved in the past and compare those solutions with the modern <code>::checkmark</code> solution, providing recommendations as we go.</p>
<p></p>
<h2>How we’ve traditionally approached checkmarks in menus</h2>
<p>To create and style a traditional checkmark similar to the ones you see in <code>&lt;option&gt;</code> elements under <code>&lt;select&gt;</code> , first, we would create a custom dropdown to house the checkmark. To do that, we would have to consider a bunch of interactive states and rules. Your browser doesn’t provide these with non-semantic elements, so taking a few notes from <a href="https://css-tricks.com/striking-a-balance-between-native-and-custom-select-elements/">Sandrina’s article</a>, we need to have:</p>
<ul>
<li>A dropdown with the current selected option</li>
<li>Box toggling ability to toggle the visibility of the options</li>
<li>Clicking an option in the list updates the dropdown value. The dropdown text changes, and the list MUST be closed</li>
<li>Clicking outside closes the list</li>
<li>A customizable select dropdown icon to represent our picker</li>
<li>A customizable select checkmark icon to represent our checkmark for any selected option</li>
</ul>
<p>Here’s how our HTML would likely look:</p>
<pre><code>&lt;div id="cs"&gt;
    &lt;div id="cs-btn"&gt;
      &lt;span id="btn-lbl"&gt;::before&lt;/span&gt;
      &lt;span class="btn-arrow"&gt;▼&lt;/span&gt;
    &lt;/div&gt;
    
    /* list menu */
    &lt;div class="cs-menu"&gt;
      &lt;div class="item" data-sel data-v=":before"&gt;
        &lt;span class="checkmark"&gt;✓&lt;/span&gt;
        &lt;span class="label"&gt;::before&lt;/span&gt;
          
        &lt;/div&gt;
      &lt;div class="item" data-v="::after"&gt;
        &lt;span class="checkmark"&gt;✓&lt;/span&gt;
        &lt;span class="label"&gt;::after&lt;/span&gt; 
        &lt;/div&gt;
      &lt;div class="item" data-v="::marker"&gt;
        &lt;span class="checkmark"&gt;✓&lt;/span&gt;
        &lt;span class="label"&gt;::marker&lt;/span&gt;  
        &lt;/div&gt;
      &lt;div class="item" data-v="::selection"&gt;
        &lt;span class="checkmark"&gt;✓&lt;/span&gt;
        &lt;span class="label"&gt;::selection&lt;/span&gt;
        &lt;/div&gt;
        
      &lt;div class="item" data-v="::placeholder"&gt;
        &lt;span class="checkmark"&gt;✓&lt;/span&gt;
        &lt;span class="label"&gt;::placeholder&lt;/span&gt;
        &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
</code></pre>
<p>And our main CSS for the dropdown would look something like this:</p>
<pre><code>/* position property sets the list dropdown menu to be centered and non-clickable, just below the button that reveals it*/
.cs-menu {
  position: absolute;
  top: calc(100% + 0.4rem);
  left: 0;
  right: 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  padding: 0.3rem;
  z-index: 20;
  box-shadow: 0 1.5rem 3rem oklch(0% 0 0 / 0.55);
  opacity: 0;
  transform: translateY(-0.4rem);
  pointer-events: none;
  transition: opacity 0.15s, transform 0.15s;
}

/* this reveals the menu list when the dropdown menu button is clicked if data-open attribute is present. It is hidden otherwise */ 
#cs[data-open] .cs-menu {
  opacity: 1;
  transform: translateY(0);
  pointer-events: all;
}

/* you can use flex here instead of grid. Just follow the pattern stated */
.item {
  display: grid;
  grid-template-columns: 1.2rem 1fr;
  align-items: center;
  gap: 0.65rem;
  padding: 0.6rem 0.75rem;
  border-radius: 0.4rem;
  cursor: pointer;
  transition: background 0.1s;
}
.item:hover {
  background: var(--hover);
}
.item[data-sel] {
  background: oklch(75% 0.18 55 / 0.08);
}

.label {
  font-size: 0.88rem;
  color: var(--muted);
  transition: color 0.12s;
}
.item[data-sel] .label {
  color: var(--accent);
}

/* checkmark over here 🙋‍♂️ */
.checkmark {
  justify-self: end;
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--accent);
  visibility: hidden;
}
.item[data-sel] .checkmark {
  visibility: visible;
}

</code></pre>
<p>In essence, we’re using <code>position</code> absolute to remove the element from the normal document flow and hide it from the user. revealing the menu only when the dropdown is clicked. Then, we’re using <code>grid</code> to align and arrange the items in the dropdown menu.</p>
<p>Finally, we style the checkmark to be hidden by default on all list items, and only the selected item should reveal the checkmark, indicating it has been chosen!</p>
<p>Ah yes! That should be all, right?</p>
<p>All this code should make sure the dropdown and checkmark work as intended, fulfilling the requirements stated above. Surely, it works without JavaScript (JS). But, enough sarcastic talk, let’s see the result…</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/GgNqgym">The dilemma of custom select dropdown with checkmarks: no js, a problem</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Notice something not working? Yes, the dropdown is not working. Why? <strong>It’s because there’s no JS to provide the interactivity!</strong></p>
<p>That’s a <em>big</em> issue with solutions like this because JS is required for them to work, and <a href="https://piccalil.li/blog/a-handful-of-reasons-javascript-wont-be-available/">JS <em>will</em> fail for your users</a>. Warnings about best practice aside, let’s provide some interactivity to at least get this sample working.</p>
<pre><code>const cs = document.getElementById("cs");
const btn = document.getElementById("cs-btn");
const lbl = document.getElementById("btn-lbl");
const items = document.querySelectorAll(".item");

btn.addEventListener("click", () =&gt; cs.toggleAttribute("data-open"));

items.forEach((item) =&gt;
  item.addEventListener("click", () =&gt; {
    items.forEach((x) =&gt; x.removeAttribute("data-sel"));
    item.setAttribute("data-sel", "");
    lbl.textContent = item.dataset.v;
    cs.removeAttribute("data-open");
  })
);

document.addEventListener("click", (e) =&gt; {
  if (!cs.contains(e.target)) cs.removeAttribute("data-open");
});

</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ogYjrZv">Custom dropdown using grid/flex with position property</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>We are missing two important points. What about accessibility and users who want to navigate via keyboard?</p>
<p></p>
<p>We will have to modify the HTML with some <code>aria-haspopup</code> , <code>aria-expanded</code>, <code>aria-selected</code>, <code>role</code>, <code>tab-index</code>, and <code>aria-label</code> . That would sort out our HTML accessibility dilemma. The code would look a lot like this. Setting it up this way from the start, although tedious, would ensure checkmarks in our dropdowns work as intended for <em>all</em> users:</p>
<pre><code>	&lt;div id="cs"&gt;
		&lt;!-- Button to open/close dropdown --&gt;
      &lt;button id="cs-btn" aria-haspopup="listbox" aria-expanded="false"&gt;
        &lt;span id="btn-lbl"&gt;::before&lt;/span&gt;
        &lt;span class="btn-arrow"&gt;▼&lt;/span&gt;
      &lt;/button&gt;
      
      &lt;!-- Menu itself --&gt;
      &lt;div class="cs-menu" 
	      id="cs-menu" 
	      role="listbox" 
	      aria-label="CSS pseudo-element"
      &gt;
        &lt;div class="item" 
	        role="option" 
	        data-sel 
	        aria-selected="true" 
	        data-v=":before"
	        tabindex="-1"
	        &gt;
          &lt;span class="checkmark"&gt;✓&lt;/span&gt;
          &lt;span class="label"&gt;::before&lt;/span&gt;
        &lt;/div&gt;
        
        &lt;div class="item" 
	        role="option" 
	        aria-selected="false" 
	        data-v="::after"
	        tabindex="-1"
	        &gt;
          &lt;span class="checkmark"&gt;✓&lt;/span&gt;
          &lt;span class="label"&gt;::after&lt;/span&gt;
        &lt;/div&gt;
        
        &lt;div class="item" 
	        role="option" 
		      aria-selected="false" 
		      data-v="::marker"
		      tabindex="-1"
		      &gt;
          &lt;span class="checkmark"&gt;✓&lt;/span&gt;
          &lt;span class="label"&gt;::marker&lt;/span&gt;
        &lt;/div&gt;
        
        &lt;div class="item" 
	        role="option" 
	        aria-selected="false" 
	        data-v="::selection"
	        tabindex="-1"
	        &gt;
          &lt;span class="checkmark"&gt;✓&lt;/span&gt;
          &lt;span class="label"&gt;::selection&lt;/span&gt;
        &lt;/div&gt;
        
        &lt;div class="item" 
	        role="option" 
	        aria-selected="false" 
	        data-v="::placeholder"
	        tabindex="-1"
	        &gt;
          &lt;span class="checkmark"&gt;✓&lt;/span&gt;
          &lt;span class="label"&gt;::placeholder&lt;/span&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
</code></pre>
<p>Then, for our JavaScript, that will be modified a bit from what you saw earlier. We would have to account for when the user uses the directional arrows <code>up</code> and <code>down</code>, and when they click <code>enter</code> and <code>esc</code> to navigate the dropdown and click on any option.</p>
<pre><code>const cs = document.getElementById("cs");
const btn = document.getElementById("cs-btn");
const lbl = document.getElementById("btn-lbl");
const items = [...document.querySelectorAll(".item")];

let selected = items.find((i) =&gt; i.hasAttribute("data-sel"));

function open() {
  cs.setAttribute("data-open", "");
  btn.setAttribute("aria-expanded", "true");
  (selected ?? items[0]).focus();
}

function close() {
  cs.removeAttribute("data-open");
  btn.setAttribute("aria-expanded", "false");
  btn.focus();
}

function select(item) {
  selected?.removeAttribute("data-sel");
  selected?.setAttribute("aria-selected", "false");
  item.setAttribute("data-sel", "");
  item.setAttribute("aria-selected", "true");
  selected = item;
  lbl.textContent = item.dataset.v;
}

btn.addEventListener("click", () =&gt;
  cs.hasAttribute("data-open") ? close() : open()
);

btn.addEventListener("keydown", (e) =&gt; {
  if (e.key === "ArrowDown") {
    e.preventDefault();
    open();
  }
});

/* accounting for keyboard up, down, enter and esc for the dropdown */
items.forEach((item) =&gt; {
  item.addEventListener("click", () =&gt; {
    select(item);
    close();
  });
  item.addEventListener("keydown", (e) =&gt; {
    const i = items.indexOf(item);
    if (e.key === "ArrowDown") {
      e.preventDefault();
      items[(i + 1) % items.length].focus();
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      items[(i - 1 + items.length) % items.length].focus();
    }
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      select(item);
      close();
    }
    if (e.key === "Escape") close();
  });
});

document.addEventListener("click", (e) =&gt; {
  if (!cs.contains(e.target)) close();
});

</code></pre>
<p>That’s <em>a lot</em> of JavaScript! And essentially, what I’m doing is accounting for when a user clicks the <code>down</code> arrow button on their keyboard, which can be used to expand the dropdown and also browse downwards through the options. The up arrow key inside the dropdown can be used to navigate up, enter selects the item, and esc leaves the dropdown.</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/NPbdVeY">custom dropdown using grid/flex with position property
</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Let’s also not forget the accessibility toggles that let the screen reader know that the dropdown has been expanded or that an item in the dropdown list has been selected.</p>
<p>This approach is how many developers have tackled the problem at hand before and here two key reasons why it is not the best solution:</p>
<ol>
<li>When JS fails, the whole solution is <strong>dead.</strong> Not even a glimmer of hope is here. No JavaScript means no interaction and the markup we added likely ends up creating even more problems for people using assistive technology</li>
<li>Every single browser behavior has to be unnecessarily implemented from scratch. From option focusing, to accounting for what happens when the user clicks the button to expand and close the dropdown and also clicks outside of the dropdown to close it, has to be implemented</li>
</ol>
<p>In essence, this solution is fragile and unreliable due to its over-reliance on JS. What’s more? Our checkmark styles are nonexistent if the dropdown cannot be revealed!</p>
<p>So, how do we solve these issues? Luckily, there’s a far less “heavy code” intensive solution that allows us to do style checkmarks anyhow we like.</p>
<p></p>
<h2>Modern day <code>::checkmark</code> styling</h2>
<p>The CSS <code>::checkmark</code> is a pseudo-element that allows us to apply styles to HTML elements that support a checkmark. It really helps remove the hassle of writing a bunch of HTML, CSS, and JS in order to make things work as they’re supposed to. Let’s take a custom dropdown and a checkmark designed using <code>::checkmark</code>.</p>
<p>Take for example, this <code>&lt;select&gt;</code>dropdown:</p>
<pre><code>&lt;select id="ls"&gt;
	&lt;option value="css"&gt;CSS&lt;/option&gt;
	&lt;option value="js"&gt;JavaScript&lt;/option&gt;
	&lt;option value="html"&gt;HTML&lt;/option&gt;
  &lt;option value="ts"&gt;TypeScript&lt;/option&gt;
  &lt;option value="rust"&gt;Rust&lt;/option&gt;
&lt;/select&gt;
</code></pre>
<p>The HTML is pretty concise when compared to the previous solution, right? It’s amazing what happens when we use semantic elements. This way, the browser handles everything for us. Accessibility, keyboard navigation, and every standard behavior we expect from our dropdown, without any hassle. Just define a dropdown using <code>&lt;select&gt;</code> with its <code>&lt;option&gt;</code>s and boom! We’re good to go.</p>
<p>Checkmarks even come out of the box with this native browser solution!</p>
<p>For our CSS, we would have something like this:</p>
<pre><code>/* required to make the checkmark stylable */ 
#ls,
::picker(select) {
  appearance: base-select;
}

#ls {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0.6rem;
  padding: 0.7rem 1rem;
}

#ls option:checked {
  color: var(--accent);
  background: oklch(78% 0.22 145 / 0.1);
}

/* checkmark style here 🙋‍♂️*/
::checkmark {
  content: "🎨";
}

/* a dot checkmark for options not checked */
#ls option:not(:checked)::checkmark {
  visibility: visible;
  content: "·";
  color: var(--muted);
}
</code></pre>
<p>All you need to do is set <code>appearance</code> to <code>base-select</code> on the <code>&lt;select&gt;</code> dropdown and you’re set for styling your checkmarks using <code>::checkmark</code> and changing the default check using <code>content</code>!</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/Pwbzqqa">::checkmark demo (no JS required!)</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>No JS required!</p>
<p>A win for <a href="https://piccalil.li/blog/its-about-time-i-tried-to-explain-what-progressive-enhancement-actually-is/">progressive enhancement</a> because it essentially accounts for all the major issues associated with creating a custom dropdown and having a custom checkmark style. This solution, even when JS fails, will still work out fine.</p>
<p>This approach is clearly better than the previous approach, but there are a few issues with this that I think we should touch on.</p>
<h2>The problem with <code>::checkmark</code></h2>
<p>It’s amazing what this pseudo-element can do and what <em>it is intended to do</em>. It’s also amazing the amount of code it saves. Not only do I need to write less code to achieve the same result (efficiency), but the browser also handles the default behavior I expect when I click outside the dropdown (the dropdown closes automatically).</p>
<p>There are a few issues with <code>::checkmark</code> that I want to address though.</p>
<ol>
<li>There’s limited browser support. At the time of writing, <a href="https://caniuse.com/mdn-css_selectors_checkmark"><code>::checkmark</code> is only supported in 3 major browsers</a> and not across the board. That would suck for a user having to experience a feature in one browser and be absent on the next.</li>
<li>Lacking full feature support: According to the documentation, <a href="https://drafts.csswg.org/css-forms-1/#checkmark"><code>::checkmark</code> is supposed to work for checkboxes, radios, and</a> options. Guess what? In browsers that currently support this, <strong>only the options from <code>&lt;select&gt;</code> are supported.</strong>  So even if I’m using this pseudo-element, I’m not getting its full feature!</li>
</ol>
<p>Both need to be looked into by browser vendors, in my opinion.</p>
<h2>Wrapping up</h2>
<p>The modern day <code>::checkmark</code> solution with native browser dropdown allows us to favor all users without having to implement our own accessibility, keyboard navigation, and dropdown behavior. Even when JavaScript fails, the solution still works! Because we're using a native <code>&lt;select&gt;</code> element, we get the correct announcements from assistive technology too.</p>
<p>My only plea is that for our checkmarks to be styled correctly, major browser vendors may need to provide full support for this feature as soon as possible, so we can style our own custom checkmarks without having to rely on JS at all, ever again.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Building dynamic toggletips using anchored container queries</title>
        <link>https://piccalil.li/blog/building-dynamic-toggletips-using-anchored-container-queries/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Daniel Schwarz]]></dc:creator>
        <pubDate>Thu, 12 Mar 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/building-dynamic-toggletips-using-anchored-container-queries/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>To add to the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries">container query options that we already have</a> (size queries, style queries, scroll-state queries), as well as the container query range syntax and many upgrades to all of the above, we can now use <em>anchored</em> container queries from Chrome 143.</p>
<p><a href="https://developer.chrome.com/blog/anchored-container-queries">Chrome’s announcement post</a> demonstrates how, if we were using anchor positioning, we could use anchored container queries to query the currently active fallback position (if any). They’ve provided a little demo in the post where a toggletip caret is anchored to the side of a toggletip, but if the toggletip needs to be anchored to a different side of the trigger due to lack of space, the caret is anchored to a different side as well. So in this case, anchored container queries are used to determine which position the toggletip is in, so that we can position the caret accordingly.</p>
<p>This is a pretty good use-case for anchored container queries, but also a great opportunity to look at how we might build toggletips in the (hopefully) near future. You’ll learn about popovers and anchor positioning, which are already baseline and will ensure that the toggletips at least work, as well as declarative anchors, ‘modern’ <code>attr()</code>, and <code>corner-shape</code>, progressive enhancements en<em>chant</em>ments that provide a range of magical benefits.</p>
<p>Here’s what we’ll be making:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/gbrMGYx">Anchored container queries demo</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>In Chrome you’ll see toggletip carets that flip to the appropriate side depending on the amount of space available, but in other web browsers you’ll get the toggletips without carets.</p>
<p>Let’s get into it!</p>
<h2>Setting up the popover and anchor associations</h2>
<p>We’ll be using popovers to mark up the toggletips, as that’s the semantic way to do so these days, but before we begin it’s worth mentioning that they’re implicitly anchored. However, since you might want to use anchored container queries without popovers, I’ll show you how to set up anchor associations anyway.</p>
<p>To set up the popover and anchor associations in a declarative but progressively enhanced way, we’ll need the following HTML markup:</p>
<pre><code>&lt;div anchor="leftButton" id="--leftPopover" popover&gt;Toggletip&lt;/div&gt;
&lt;div anchor="centerButton" id="--centerPopover" popover&gt;Toggletip&lt;/div&gt;
&lt;div anchor="rightButton" id="--rightPopover" popover&gt;Toggletip&lt;/div&gt;

&lt;button id="leftButton" popovertarget="--leftPopover"&gt;Button&lt;/button&gt;
&lt;button id="centerButton" popovertarget="--centerPopover"&gt;Button&lt;/button&gt;
&lt;button id="rightButton" popovertarget="--rightPopover"&gt;Button&lt;/button&gt;
</code></pre>
<p>The most familiar part of this is probably the popover markup. We’ve given the toggletips the <code>popover</code> attribute, and then an <code>id</code> so that each trigger can reference a popover using the <code>popovertarget</code> attribute. The strange part is that we’ve used custom-ident values (e.g., <code>--leftPopover</code>), but I’ll explain why shortly. That’s the popover functionality set up.</p>
<p>For the anchor positioning we’ve also given <code>id</code>s to the triggers, and then referenced them from <code>anchor</code> attributes set on the toggletips. These values <em>don’t</em> have to be custom idents.</p>
<p>However, for web browsers that don’t support the <code>anchor</code> attribute, we’ll need to use CSS instead, and there’s a shorter and a longer way to do that depending on whether or not modern <code>attr()</code> is supported.</p>
<p>The shorter, modern <code>attr()</code> way:</p>
<pre><code>/* Modern attr() supported */
@supports (x: attr(x type(*))) {
  button {
    /* Reuse the name from popovertarget */
    anchor-name: attr(popovertarget type(&lt;custom-ident&gt;));
  }

  [popover] {
    /* Anchor to the relevant button */
    position-anchor: attr(id type(&lt;custom-ident&gt;));
  }
}
</code></pre>
<p>Basically, if modern <code>attr()</code> is supported, we reuse the <code>popovertarget</code> values of the buttons as anchor names while making it clear that we want to parse them as custom idents. After that, we match the <code>id</code> values of the popovers to said anchors, thus setting up the anchor positioning associations. The <code>anchor-name</code> and <code>position-anchor</code> CSS properties only accept custom idents (e.g. <code>--leftPopover</code>), so that’s why we're making a big fuss about them.</p>
<p>Next, if modern <code>attr()</code> <em>isn’t</em> supported, name each anchor manually:</p>
<pre><code>/* Modern attr() not supported */
@supports not (x: attr(x type(*))) {
  /* Assign anchor name */
  #leftButton {
    anchor-name: --leftButton;
  }

  /* Anchor to button */
  #leftPopover {
    position-anchor: --leftButton;
  }

  /* And so on... */
  #centerButton {
    anchor-name: --centerButton;
  }

  #centerPopover {
    position-anchor: --centerButton;
  }

  /* And so on... */
  #rightButton {
    anchor-name: --rightButton;
  }

  #rightPopover {
    position-anchor: --rightButton;
  }
}
</code></pre>
<p>Once modern <code>attr()</code> is <em>Baseline widely available</em>, we can delete this block, and then once the <code>anchor</code> attribute is <em>Baseline widely available</em>, we can delete <em>all</em> of this CSS. You won’t even need to use custom-ident values, so <code>--leftPopover</code> can become <code>leftPopover</code>, for example.</p>
<p>Note: if we’d rather that the toggletips be triggered on <code>:hover</code> (making them tooltips instead of toggletips), we can swap <code>popovertarget</code> for <code>interestfor</code>, but note that <a href="https://css-tricks.com/a-first-look-at-the-interest-invoker-api-for-hover-triggered-popovers/">interest invokers</a> are only supported in Chrome 142 and above. Keep them in mind for the future!</p>
<p></p>
<h2>Aligning and styling the toggletips</h2>
<p>Like last time, I’ll drop the code right here so you can skim through it before we look at it line-by-line:</p>
<pre><code>[popover] {
  position: fixed;

  /* Align to right/implied center */
  position-area: right;

  /* If no space, flip to other inline side */
  position-try: flip-inline;

  /*
    1rem of spacing between the button and
    toggletip that also flips side accordingly
  */
  margin-inline-start: 1rem;

  /* Anchor toggletip caret to this */
  anchor-name: --toggletip;

  /* Used to query this container’s fallbacks */
  container-type: anchored;

  /* Scooped corners, if supported */
  @supports (corner-shape: squircle) {
    border-radius: 3rem;
    corner-shape: squircle;
  }

  /* Normal corners, if not supported */
  @supports not (corner-shape: squircle) {
    border-radius: 1rem;
  }
}
</code></pre>
<p>First, we must declare <code>position: fixed</code> on the anchored element (in this case though, popovers already have it).</p>
<p>Next, we need to position the toggletip relative to the button (let’s say the center-right) using the <code>position-area</code> property. Now, you might think that <code>position-area: center right</code> is the correct declaration here, but here’s what that actually does:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/anchor-toggle-tips-wrong.png" alt="An Inset-Modified Containing Block where an anchored toggletip is overflowing the center-right tile." /></p>
<p>As you can see, it’s overflowing the center-right tile, which is wrong but forgiving, since web browsers declare <code>align-self: anchor-center</code> under the hood to make it work anyway. This is declared on what’s called the Inset-Modified Containing Block (IMCB), which again is the center-right tile.</p>
<p>However, <a href="https://nerdy.dev/why-isnt-my-position-try-fallback-working-in-small-spaces">for reasons unknown, web browsers aren’t so forgiving when a fallback position is activated</a>. The trick is to use <code>position-area: right</code> instead. By omiting the <code>center</code> keyword, it resolves to <code>position-area: span-all right</code>, which does this:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/anchor-toggle-tips-right.png" alt="An Inset-Modified Containing Block where an anchored toggletip is contained within the right-side tiles and then vertically centered." /></p>
<p>Now the toggletip <em>does</em> fit into the tile (IMCB). And again, it’s vertically aligned within the center of it due to <code>align-self: anchor-center</code>.</p>
<p>We can safely use <code>position-try: flip-inline</code>, which turns <code>right</code> into <code>left</code> whenever the toggletip overflows the viewport. Later on, anchored container queries will query whether this is happening or not.</p>
<p>After that, <code>margin-inline-start: 1rem</code> adds <code>1rem</code> of spacing between the button and the toggletip at the start of the inline axis, which also flips accordingly.</p>
<p></p>
<p><code>anchor-name: --toggletip</code> turns the popovers into anchors so that we can create toggletip carets and anchor them to said popovers. Normally this’d be a naming collision waiting to happen (because three different popovers are now called <code>--toggletip</code>, right?), but the toggletip carets will be nested inside the popovers, so they’re somewhat scoped.</p>
<p>And remember, we’ll be flipping the toggletip’s caret to the opposite side whenever the toggletip is flipped, and that’s what <code>container-type: anchored</code> is for — it’s a totally new type of container and we’ll be querying it’s fallback position to determine the correct side.</p>
<p>One more thing, though…</p>
<p>If <code>corner-shape: squircle</code> is supported (<code>@supports (corner-shape: squircle)</code>), then we use it with <code>border-radius: 3rem</code> to create corners that aren’t quite rounded but aren’t quite square either. If it’s <em>not</em> supported then we just use <code>border-radius: 1rem</code>. This is optional of course, but <code>corner-shape</code> will play a bigger role when we use it again in a moment:</p>
<pre><code>/* Scooped corners, if supported */
@supports (corner-shape: squircle) {
	border-radius: 3rem;
	corner-shape: squircle;
}

/* Normal corners, if not supported */
@supports not (corner-shape: squircle) {
	border-radius: 1rem;
}
</code></pre>
<h2>Creating and positioning the toggletip’s caret</h2>
<p>Similar to how we anchored the toggletips to the buttons, the following code anchors the toggletip carets to the toggletips:</p>
<pre><code>[popover] {
	/* Previous section code */

  /* Toggletip caret */
  &amp;::after {
    /*
      Only create carets if anchored
      container queries are supported
    */
    @supports (container-type: anchored) {
      /* Create caret */
      content: "";

      /* Anchor to toggletip */
      position: fixed;
      position-anchor: --toggletip;

      /* Scooped carets, if supported */
      @supports (corner-shape: scoop) {
        height: 1rem;
        aspect-ratio: 1/2;
        corner-shape: scoop;
        background: inherit;
      }

      /* Normal carets, if not supported */
      @supports not (corner-shape: scoop) {
        /* Hack to create a triangle */
        width: 0;
        height: 0;
        border-top: 0.5rem solid transparent;
        border-bottom: 0.5rem solid transparent;
      }

      /* If no fallback */
      @container anchored(fallback: none) {
        /* Position caret on the left */
        position-area: left;

        /* Needed for scooped carets */
        @supports (corner-shape: scoop) {
          border-top-left-radius: 100% 50%;
          border-bottom-left-radius: 100% 50%;
        }

        /* Needed for normal carets */
        @supports not (corner-shape: scoop) {
          /* Part of the triangle hack */
          border-right: 0.5rem solid var(--toggletip-color);
        }
      }

      /* If flip-inline fallback triggered */
      @container anchored(fallback: flip-inline) {
        position-area: right;

        @supports (corner-shape: scoop) {
          border-top-right-radius: 100% 50%;
          border-bottom-right-radius: 100% 50%;
        }

        @supports not (corner-shape: scoop) {
          border-left: 0.5rem solid var(--toggletip-color);
        }
      }
    }
  }
}
</code></pre>
<p>First of all, the toggletip carets are CSS-generated using the <code>::after</code> pseudo-element:</p>
<pre><code>[popover] {
  &amp;::after {
    /* Toggletip caret */
	}
}
</code></pre>
<p>Then, within that, we need to see if the web browser supports anchored container queries (if not, the toggletip carets simply aren’t generated, although the toggletips will still work):</p>
<pre><code>[popover] {
  &amp;::after {
		@supports (container-type: anchored) {
	    /*
	      Only create carets if anchored
	      container queries are supported
	    */
		}
	}
}
</code></pre>
<p>Within that, <code>content: ""</code> generates the toggletip carets (or, rather, it generates a pseudo-element with no content that we’ll style <em>into</em> carets).</p>
<p><code>position: fixed</code> and <code>position-anchor: --toggletip</code> anchors them to the toggletips.</p>
<p>After that, we again leverage <code>corner-shape</code> to create scooped toggletips. Or, if <code>corner-shape</code> isn’t supported, then we use the classic <a href="https://css-tricks.com/snippets/css/css-triangle/">border trick to create triangle-shaped carets</a>.</p>
<p>We also — finally — implement those anchored container queries to position the carets to the correct side of the toggletips. When a fallback isn’t active, <code>@container anchored(fallback: none)</code> will match, but when a fallback <em>is</em> active, <code>@container anchored(fallback: flip-inline)</code> will match. The value of <code>fallback</code> simply needs to match a value of <code>position-try</code>:</p>
<p><code>position-try: flip-inline</code>, in our case</p>
<pre><code>/* Scooped carets, if supported */
@supports (corner-shape: scoop) {
	height: 1rem;
	aspect-ratio: 1/2;
	corner-shape: scoop; /* (Border radius is set later) */
	background: inherit;
}

/* Normal carets, if not */
@supports not (corner-shape: scoop) {
	/* Hack to create a triangle */
	width: 0;
	height: 0;
	border-block: 0.5rem solid transparent; /* (Side is set later) */
}

/* If no fallback */
@container anchored(fallback: none) {
	/* Position caret on the left */
	position-area: left;

	/* Left border radii for scooped carets */
	@supports (corner-shape: scoop) {
		border-top-left-radius: 100% 50%;
		border-bottom-left-radius: 100% 50%;
	}

	/* ‘Left’ border for normal carets */
	@supports not (corner-shape: scoop) {
		border-right: 0.5rem solid var(--toggletip-color);
	}
}

/* If flip-inline fallback triggered */
@container anchored(fallback: flip-inline) {
	position-area: right;

	/* Right border radii for scooped carets */
	@supports (corner-shape: scoop) {
		border-top-right-radius: 100% 50%;
		border-bottom-right-radius: 100% 50%;
	}

	/* ‘Right’ border for normal carets */
	@supports not (corner-shape: scoop) {
		border-left: 0.5rem solid var(--toggletip-color);
	}
}
</code></pre>
<p>As a reminder, here’s the final demo:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/gbrMGYx">Anchored container queries demo</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<h2>Wrapping up</h2>
<p>Container queries are evolving beautifully, especially with the newest addition, <em>anchored</em> container queries. And, thanks to other modern CSS features such as the <code>anchor</code> attribute, ‘modern’ <code>attr()</code>, <code>corner-shape</code>, and even interest invokers, we can include all kinds of progressive enhancements to create some really awesome — in this case — toggletips.</p>
<p>If you want to go ahead and implement this (full code in <a href="https://codepen.io/mrdanielschwarz/pen/gbrMGYx">the demo</a>), you totally can. If something isn’t supported it’ll simply revert to something that is, and then as new features become baseline, you can very easily remove the code that’s no longer needed.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>An in-depth guide to customising lists with CSS</title>
        <link>https://piccalil.li/blog/an-in-depth-guide-to-customising-lists-with-css/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Richard Rutter]]></dc:creator>
        <pubDate>Thu, 19 Feb 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/an-in-depth-guide-to-customising-lists-with-css/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>This first rule of styling lists is that they should be treated with the same reverence you would show any other text. If a list is inserted within a passage of text, treat it as a continuation and integral part of that text.</p>
<p>For bulleted or <dfn>unordered</dfn> lists, use padding to indent each list item the equivalent distance of a line height. This will allow the bullet to sit neatly in a square of white-space.</p>
<pre><code>ul {
	padding-inline-start: 1lh;
}
</code></pre>
<p>Numbered or <dfn>ordered</dfn> lists which reach into double figures and beyond will require more space. Allocate this space in multiples and fractions of the line height.</p>
<h2>The basics: choosing different kinds of lists</h2>
<h3>Unordered lists</h3>
<p>Unordered lists are given filled <strong>discs</strong> for bullets by default. As lists are further nested, browsers apply <strong>circles</strong>. These are followed by filled <strong>squares</strong> for lists nested three or more deep.</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/WbxmjZm">Lists: example 0 - default nested lists</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>You can change which bullets are used through the <code>list-style-type</code> property with values of <code>disc</code>, <code>circle</code> and <code>square</code> respectively. For example, to make all unordered list items filled squares use:</p>
<pre><code>ul li {
	list-style-type: square;
}
</code></pre>
<p>If you want to use a different symbol for a bullet you can do so by specifying it in quote marks. You’re not limited to individual characters – as well as regular glyphs, you can use words and emoji.</p>
<p>The <code>-type</code> suffix of <code>list-style-type</code> implies that the bullets are typographical. You can also use images for your list bullets. Link to your image using the <code>list-style-image</code> property.</p>
<p>You can position the bullet in-line with your text by using <code>list-style-position: inside</code>. Use <code>outside</code> to set it back again.</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/VYjroYK">Lists: example 1 - basic list-style-type</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<pre><code>ul li.point {
	list-style-type: "👉️ ";
}

ul li.tool {
	list-style-image: url(wrench.svg);
}

ul.notes li {
	list-style-type: "Note: ";
	list-style-position: inside;
}
</code></pre>
<h3>Ordered lists</h3>
<p>The default style for numbered or <dfn>ordered</dfn> lists is a decimal progression, with each number followed by a full stop. You can change the numbering system to alphabetical or Roman numerals by applying the <code>lower-latin</code>, <code>upper-latin</code>, <code>lower-roman</code>, <code>upper-roman</code> or <code>decimal</code> values of <code>list-style-type</code>. You can also select from many non-Latin numbering systems such as Greek, Devanagari, Persian and Katakana – you’ll need to use these even when the document or element has the appropriate language set.</p>
<h3>Changing font and colour</h3>
<p>Numbering and bullets will inherit the colour and font of your list text. While this will provide your reader with a consistent experience, you may wish to style them differently to reduce their visual impact, or otherwise distinguish them from the text.</p>
<p>The bullets and numbering automatically displayed by browsers are known as <dfn>markers</dfn> in CSS. The good news is that these are created as <code>::marker</code> pseudo-elements which means you can style them directly. The bad news is that we are very limited in which styles we can apply. All you can change is the colour (not the background) and the font via any property beginning with <code>font-</code> such as family, weight and size. You can also animate these properties and specify directionality of the text.</p>
<p>Despite these limitations, being able to style the colour and font of the markers accounts for a common use case that required workarounds and extra markup in the past. For example, applying these styles directly to the markers…</p>
<pre><code>ol li::marker {
	color: gray;
	font-family: sans-serif;
	font-size: 0.8em;
}
</code></pre>
<p>…will reduce the visual impact of your numbering by making it grey, sans-serif and slightly smaller.</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ogLoKxL">Lists: example 2 - styling using ::marker</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>By default, list markers use tabular numerals, meaning the numbers all line up nicely. If you wanted a different effect you would need to add <code>font-variant-numeric: proportional-nums</code> to your marker styling.</p>
<p>You might also be tempted to make your numeric markers much larger than your list text. This can be a pleasing effect, however simply changing the <code>font-size</code> of your marker to <code>3em</code> probably won’t achieve the result you’re hoping for:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/vEKWoXm">Lists: example 3 - changing font size using ::marker</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>The gotcha is that browsers align the baseline of markers with that of the first line of the list item text. Therefore a large list item number will always stick up above the list item text. And because we can’t apply any positioning or layout properties directly to the marker there’s not much you can do about it, although there are alternatives which we’ll come to later.</p>
<p></p>
<h2>Generating your own marker content</h2>
<p>You can customise the generated content of the marker as it also takes the <code>content</code> property, like this:</p>
<pre><code>ul li::marker {
	/* make all the bullets pointing hand emoji */
  content: "👉️ ";
}

ol li::marker {
	/* follow each number by a parenthesis instead of a full stop */
  content: counter(list-item) ") ";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/NPrwQbB">Lists: example 4 - generated content in ::marker</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Generating content for markers is supported by Chromium and Firefox, but not by WebKit (Safari), with no support in sight at the time of writing. Safari falls back to regular bullets or numbering which may, or may not, be acceptable to you depending on whether you consider your styling a progressive enhancement rather than crucial to the meaning of your content.</p>
<p>So what is <code>::marker</code> good for? It’s definitely the way to go if you want to change just the colour or font of your list markers. Anything more and you’ll need a different technique. Before we get on to those we need to talk about symbols and counters.</p>
<p>So far we have been defining what the bullets in an entire list should look like, that is to say we’re using the same symbols for all list items at the same level of nesting. However it is also possible to define a sequence of symbols for your bullets. There are a couple of ways to do this. The first is with the <code>symbols()</code> function, which you can use as a value of the <code>list-style</code> property.  For example, you could specify the classic sequence of symbols for footnotes:</p>
<pre><code>ol {
	list-style: symbols("*" "†" "‡" "§");
}
</code></pre>
<p><img src="https://piccalil.b-cdn.net/images/blog/css-symbols-list.jpg" alt="The symbols, assigned in the above code block, rendered in Firefox" title="At the time of writing, this only works in Firefox. You can still &lt;a href='https://codepen.io/clagnut/pen/dPXZxRv'&gt;see the demo here though&lt;/a&gt;" /></p>
<p>In this case the symbols automatically double up when the end of your specified sequence is reached. You can add the <code>cyclic</code> keyword to prevent this. The default behaviour is <code>symbolic</code>.</p>
<pre><code>ul {
	list-style: symbols(cyclic "🌑" "🌓" "🌕" "🌗");
}
</code></pre>
<p><img src="https://piccalil.b-cdn.net/images/blog/css-symbols-list-2.jpg" alt="The symbols, assigned in the above code block, rendered in Firefox" title="At the time of writing, this only works in Firefox. You can still &lt;a href='https://codepen.io/clagnut/pen/qENVePa'&gt;see the demo here though&lt;/a&gt;" /></p>
<p>You can also specify that your symbols sequence is <code>numeric</code> like decimals …7 8 9 10 11… or <code>alphabetic</code> like …a b c aa ab ac ba…, or use <code>fixed</code> to revert to default numbering once your custom symbols run out – useful if you’re specifying a sequence like ① ②…⑧ ⑨.</p>
<p><em>However,</em> the <code>symbols()</code> function is <strong>only supported in Firefox</strong>, with no interest from <a href="https://bugs.webkit.org/show_bug.cgi?id=299922">Webkit</a> or <a href="https://issues.chromium.org/484510686">Chromium</a> at the time of writing. In theory you can also specify a sequence of images, but this is not supported in any browser.</p>
<p>The <a href="https://drafts.csswg.org/css-counter-styles/#symbols-function">CSS spec</a> says <q>The <code>symbols()</code> function allows a counter style to be defined inline in a property value, for when a style is used only once in a stylesheet and defining a full <code>@counter-style</code> rule would be overkill.</q>. And this brings us to some good news.</p>
<h2>Defining your own numbering sequence</h2>
<p>The <code>@counter-style</code> rule enables you to further specify exactly what symbols or text appear in your list markers, and in what order or combination. You can use it to replicate all the <code>symbols()</code>  and <code>::marker</code> content functionality we’ve seen so far, and cross-browser is support is good – it became <a href="https://web-platform-dx.github.io/web-features-explorer/features/counter-style/">Baseline Newly Available</a> in September 2023.</p>
<p>This is how to use <code>@counter-style</code> to achieve the <code>symbols()</code> examples we looked at earlier. This will work across all modern browsers today:</p>
<pre><code>@counter-style --footnotes {
  system: symbolic;
  symbols: '*' † ‡ §;
  suffix: " ";
}

@counter-style --moons {
  system: cyclic;
  symbols: 🌑 🌓 🌕 🌗;
  suffix: " ";
}
</code></pre>
<p>Which you apply to your lists using the <code>list-style</code> property:</p>
<pre><code>ol.footnotes { 	list-style: --footnotes; }
ul.moons { list-style: --moons; }
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/bNeYXOz">Lists: example 7 - @counter-style demo</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Similarly one of the marker examples attempted to use a 👉️ for all its bullets. You can achieve that effect cross-browser like this:</p>
<pre><code>@counter-style --point {
  system: cyclic;
  symbols: 👉️;
  suffix: " ";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/WbxXVPW">Lists: example 8 - @counter-style example with pointing hand bullets</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>These counter style examples consist of four parts. The first is a name for the style, such as <code>--point</code>. You can call the counter styles pretty much what you want. You don’t need to prefix them with <code>--</code> although I find it good practise to do so in order to be consistent with naming custom properties. The other parts of the rule are:</p>
<ul>
<li><code>system</code> which sets the algorithm used for how the counter works. This takes the same keywords as the <code>symbols()</code> function described earlier, plus <code>additive</code> and <code>extends</code> which we’ll come to later.</li>
<li><code>symbols</code> is a space separated list of numbers or bullets (in theory this can also take images but there’s no support at the time of writing). Some characters need to be quoted such as <code>*</code>  – to be safe you could put all the characters inside quotes, but generally you don’t need to.</li>
<li><code>suffix</code> is the character or characters inserted after the counter (a simple space in our examples so far).</li>
</ul>
<p>For completeness, counter style rules also take the following descriptors, some of which are more niche than others:</p>
<ul>
<li><code>prefix</code> is the character or characters inserted before the counter</li>
<li><code>negative</code> sets the characters before and after a negative counter value. For example, specifying <code>negative: "(" ")"</code> will wrap negative counters in parentheses, which is sometimes used in financial contexts, like “(2) (1) 0 1 2 3…”. It’s fair to say there are very few use cases for negatively numbered ordered lists, however this is still a good opportunity to specify a proper minus symbol instead of a hyphen, just in case: <code>negative: "−"</code>.</li>
<li><code>pad</code> gives you a fixed-width style of numbering by repeating a character before the counter such that all counters are the same width. In reality this means zero-padded decimal numbering. So if you know that your ordered list goes up to less than a thousand, you can specify <code>pad: 3 "0"</code> to ensure that all counters are (at least) 3 digits wide. This will cause 1 to be shown as "001", 20 as "020", 300 as "300", 4000 as "4000", and -5 as "-05" (still three characters wide).</li>
<li><code>range</code> enables you apply counter styling to specific ranges in the ordered list. For example adding <code>range: 3 5, 20 infinite</code> will only apply styles to list items with counters 3,4 ,5 and anything 20 and above. All other list items will get the default style of numbering.</li>
<li><code>speak-as</code> describes how assistive technologies should synthesise the spoken form of a counter. Our earlier example had <code>symbols: 🌑 🌓 🌕 🌗</code>. To prevent the browser introducing each list item with something like ‘first quarter moon’, you could set <code>speak-as: bullets</code> so they are introduced like a normal unordered list. See <a href="https://drafts.csswg.org/css-counter-styles-3/#counter-style-speak-as">the spec</a> for more options. Only Safari supports this at the moment but it’s a good candidate for progressive enhancement.</li>
</ul>
<p>For more examples and creative uses of <code>@counter-style</code> see <a href="https://css-tricks.com/some-things-you-might-not-know-about-custom-counter-styles/">Geoff Graham’s CSS Tricks article</a>.</p>
<p>Thinking back to our other marker example, we followed the number by a closing parenthesis instead of a full stop. This is a perfect use case for the <code>extends</code> system. Instead of starting from scratch and working out how to define decimal counting in a counter style, we can ‘extend’ the existing decimal style. You might like to think of <code>extends</code> as meaning ‘modifies’. Here’s how:</p>
<pre><code>@counter-style --decimalparen {
  system: extends decimal;
  suffix: ") ";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ByzJQYX">Lists: example 9 - @counter-style demo of 'extends' system</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>You can’t include a <code>symbols</code> descriptor when extending a counter style as that would imply  you’re defining a brand new style. You can extend any counter system from the standard list-types as well as any named custom counter styles.</p>
<p>The space character in the suffixed <code>") "</code>  exposes a slight inconsistency between browsers. If you take the space away you’ll see that Chrome and Firefox position the marker flush against the list item text. However Safari always introduces a gap, which you can’t remove. Most of the time this difference is subtle and inconsequential, but once you start heavily customising your markers it can be more obvious. This is particularly true if you’re increasing the font size of your markers, but as we saw earlier that brought its own problem with the marker being aligned to the first line of text.</p>
<p></p>
<h2>Creating your own marker box</h2>
<p>The only solution to all these inconveniences is to remove the <code>::marker</code> pseudo element and create your own in such a way that you can style it to your specific purposes.</p>
<p>Firstly remove the marker pseudo element by applying <code>list-style:none</code> to your list:</p>
<pre><code>ol {
	list-style: none;
}
</code></pre>
<p>Having done that, Safari will no longer announce your list to assistive software. To rectify this, tell browsers this really is a list by applying an ARIA role:</p>
<pre><code>&lt;ol role="list"&gt;
	&lt;li&gt;First item.
	  &lt;ol role="list"&gt;
			&lt;li&gt;Nested item.&lt;/li&gt;
		&lt;/ol&gt;
	&lt;/li&gt;
	&lt;li&gt;Second item.&lt;/li&gt;
&lt;/ol&gt;
</code></pre>
<p>Now create your own placeholder for the counter by creating <code>::before</code> pseudo elements and moving them into the margin. Then use generated content to display the counter using the <code>counter()</code> function.</p>
<pre><code>ol[role='list'] li::before {
	display:inline-block;
	width:4ch;
	padding-inline-end:1ch;
	margin-inline-start:-5ch;
	text-align:right;
	font-variant-numeric: tabular-nums; /* make the numbers line up nicely */

	content: counter(list-item) ".";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/KwMZNYa">Lists: example 10 - generating custom list marker elements using li::before</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>The <code>counter()</code> function takes a counter name, which you can specify. In this case we’ve used  <code>list-item</code> which is a special name reserved as an <dfn>implicit counter</dfn> for the list being styled. It outputs the position of the list item.</p>
<p>You can also output the position of all the list numbers in nested sequence, separated by a character or string. To do this use the <code>counters()</code> function:</p>
<pre><code>ol[role='list'] li::before {
	content: counters(list-item,".") ":";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/MYerbMv">Lists: example 11 - demo of counters() function for nested numbering</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>If you want your list to start at 1 and increment by 1 each time, you don’t need to do anything else provided you use <code>list-item</code> as your counter name. Otherwise you can reset or create a new counter, set its starting point (the default is 0) and increment by a defined amount for each list item:</p>
<pre><code>ol[role='list'].mylist {
	list-style: none;
	counter-reset: --myList 3;
}

ol[role='list'].mylist li {
	counter-increment: --myList 2;
}

ol[role='list'].mylist li::before {
	content: counter(--myList) ". ";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/QwEaQqw">Lists: example 12 - counter reset and increment</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>You can use <code>counter-increment</code> with any selector meaning you can just as easily number headings in sequence:</p>
<pre><code>h2 {
	counter-increment: --h2;
}

h2::before {
	content: counter(--h2) ". ";
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/VYjyQrB">Lists: example 13 - automatically numbering headings</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>If all you want to do is set the start number for a list or adjust an item’s value, you don’t need to use CSS, you can do it in HTML using <code>start</code> and <code>value</code>. If you want to have the list items count down instead of up you’ll need to specify that in HTML with a <code>reverse</code> attribute:</p>
<pre><code>&lt;ol start="42"&gt;
  &lt;li&gt;First item starts at 42&lt;/li&gt;
  &lt;li&gt;This one is then 43&lt;/li&gt;
  &lt;li&gt;And we end on 44&lt;/li&gt;
&lt;/ol&gt;
</code></pre>
<pre><code>&lt;ol&gt;
  &lt;li&gt;This is number 1&lt;/li&gt;
  &lt;li value="42"&gt;This is number 42&lt;/li&gt;
  &lt;li&gt;It follows that this is 43&lt;/li&gt;
&lt;/ol&gt;
</code></pre>
<pre><code>&lt;ol reversed start="44"&gt;
  &lt;li&gt;Counting down from 44&lt;/li&gt;
  &lt;li&gt;Now we're at 43&lt;/li&gt;
  &lt;li&gt;The final answer.&lt;/li&gt;
&lt;/ol&gt;
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ByzJYVX">Lists: example 14 - HTML start, value and reversed</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<h2>Tying it all together</h2>
<p>Finally let’s use some of what we’ve learned to create this list:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ZYOOReL">Lists: example 15 - fancy list markers</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Here’s the full CSS which we’ll walk through afterwards:</p>
<pre><code>ul[role='list'] {
  list-style: none;
  max-inline-size:23em;
  padding-inline-start:2.5em;
  line-height:1.25em;
}

ul[role='list'] li {
  margin-block-end:1lh;
  min-block-size: 2lh;
  position:relative;
  text-box: trim-start cap alphabetic;
}

@counter-style --fleurons {
  system: cyclic;
  symbols: ❦ ✾ ✤ ❈ ✺ ❥;
  suffix: "";
  speak-as: bullets;
}

ul[role='list'] li::before {
  position:absolute;
  inset-block-start: 0;
  inset-inline-start: -1.25em;
  inline-size:1em;
  text-box: trim-start cap alphabetic;
  text-align:end;
  font-size:2lh;
  line-height:1;
  font-weight:bold;
  color: hsl(3, 70%, 55%);
  content: counter(list-item, --fleurons);
}
</code></pre>
<h3>Breakdown</h3>
<p>Looking at each rule individually:</p>
<pre><code>ul[role='list'] {
  list-style: none;
  max-inline-size:23em;
  padding-inline-start:2.5em;
  line-height:1.25em;
}
</code></pre>
<p>We start by removing the default bullets, remembering to tell assistive software the list is still a list. We then set enough padding next to the list to accommodate our fancy new bullets.</p>
<pre><code>ul[role='list'] li {
  margin-block-end:1lh;
  min-block-size: 2lh;
  position:relative;
  text-box: trim-start cap alphabetic;
}
</code></pre>
<p>Next we address the list items themselves, spacing them apart using the list’s line-height to stick to a vertical rhythm. We give each item a minimum height to fit a bullet symbol, and set to relative positioning in preparation for our custom list markers. Finally we trim the text to the top of the capital letters to help us align our bullets consistently.</p>
<pre><code>@counter-style --fleurons {
  system: cyclic;
  symbols: ❦ ✾ ✤ ❈ ✺ ❥;
  suffix: "";
  speak-as: bullets;
}
</code></pre>
<p>We now define a sequence of fancy Unicode symbols to use as bullets. These will cycle round if we end up with more than six list items. We make sure they are announced  as ‘bullet’ rather than ‘rotated heavy black heart’, etc.</p>
<pre><code>ul[role='list'] li::before {
  position:absolute;
  inset-block-start: 0;
  inset-inline-start: -1.25em;
  inline-size:1em;
  text-box: trim-start cap alphabetic;
  text-align:end;
  font-size:2lh;
  line-height:1;
  font-weight:bold;
  color: hsl(3, 70%, 55%);
  content: counter(list-item, --fleurons);
}
</code></pre>
<p>Finally we create pseudo elements before each list item and absolutely position them off to the side, leaving a 0.25em gap. We want the bullets to nicely span two lines of text, so we make the font size <code>2lh</code>, remove any half-leading by setting the line height to <code>1</code>,  and trim the text box to the top of the capitals so it aligns with our list text. Finally we make the bullet bold and red, and write it to the page using generated content by way of the <code>list-item</code> implicit counter and our <code>--fleurons</code> counter style.</p>

<p>There could be a case for using anchor positioning here, however good old fashioned absolute positioning has 100% support and provides a very simple solution to the task at hand.</p>

<h2>In summary</h2>
<p>Phew. Modern CSS enables you to do so much with lists it’s sometimes hard to know where to start, especially as browser support is not complete. Here’s a summary of which properties you should turn to for different use cases.</p>

































<table><thead><tr><th>CSS</th><th>Use case</th></tr></thead><tbody><tr><td><code>list-style</code></td><td>Changing the basic bullet styles or numbering system. Using a Unicode symbol, emoji or text in place of a bullet. Using images for bullets.</td></tr><tr><td><code>li::marker</code></td><td>Colouring the numbering or bullets differently to the list text. Changing the <code>font-</code> properties of the numbering (but not its size unless the difference is subtle).</td></tr><tr><td><code>symbols()</code></td><td>Only supported by Firefox, use <code>@counter-style</code> instead.</td></tr><tr><td><code>@counter-style</code></td><td>For defining your own sequence of bullet symbols (not images) or a completely customised numbering system.</td></tr><tr><td><code>extends</code></td><td>Used within <code>@counter-style</code> to modify existing numbering systems, for example to change or remove the default "." suffix.</td></tr><tr><td><code>li::before</code></td><td>For complete control over marker positioning, especially if your bullets or numbering are much larger than the list text.</td></tr></tbody></table>
<h2>Sources and further reading</h2>
<p>I am indebted to the following articles, in no particular order:</p>
<ul>
<li><a href="https://kizu.dev/list-item-counter/">https://kizu.dev/list-item-counter/</a></li>
<li><a href="https://moderncss.dev/totally-custom-list-styles/">https://moderncss.dev/totally-custom-list-styles/</a></li>
<li><a href="https://hadrysmateusz.com/blog/css-list-styling">https://hadrysmateusz.com/blog/css-list-styling</a></li>
<li><a href="https://web.dev/articles/css-marker-pseudo-element">https://web.dev/articles/css-marker-pseudo-element</a></li>
<li><a href="https://css-tricks.com/some-things-you-might-not-know-about-custom-counter-styles/">https://css-tricks.com/some-things-you-might-not-know-about-custom-counter-styles/</a></li>
<li><a href="https://www.smashingmagazine.com/2019/07/css-lists-markers-counters/">https://www.smashingmagazine.com/2019/07/css-lists-markers-counters/</a></li>
</ul>
<p>Specifications:</p>
<ul>
<li><a href="https://drafts.csswg.org/css-counter-styles/">https://drafts.csswg.org/css-counter-styles/</a></li>
<li><a href="https://drafts.csswg.org/css-lists/">https://drafts.csswg.org/css-lists/</a></li>
<li><a href="https://html.spec.whatwg.org/multipage/grouping-content.html#the-ol-element">https://html.spec.whatwg.org/multipage/grouping-content.html#the-ol-element</a></li>
</ul>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Some CSS only contrast options until contrast-color() is Baseline widely available</title>
        <link>https://piccalil.li/blog/some-css-only-contrast-options-until-contrast-color-is-baseline-widely-available/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Donnie D’Amato]]></dc:creator>
        <pubDate>Tue, 03 Feb 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/some-css-only-contrast-options-until-contrast-color-is-baseline-widely-available/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>For as long as I’ve been making things on the web, getting a foreground color to “just work” with any given background color has been a persistent wish of mine.</p>
<p>Initially I thought this would be helpful with prototyping because I could choose a single color for the background, and the foreground color could automatically update to become at least readable. This reduces the number of decisions I need to make and allows for faster development.</p>
<p>You can probably imagine how this might be helpful in production as well because dozens of design tokens meant to describe the presentation of an experience could be reduced if some of these decisions happened behind the scenes. The colors that truly encompass the brand and its expression could be carefully chosen, while other supporting colors could “just work”.</p>
<p>Browser engineers have been working towards a solution for this in CSS called <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/contrast-color"><code>contrast-color()</code></a>. Here’s what that will look like:</p>
<pre><code>body {
	background: black;
	color: contrast-color(black);
}
</code></pre>
<p>The color used for the background is passed as an argument in the CSS <code>contrast-color()</code> function to produce either black or white. Unfortunately, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/contrast-color">this isn’t available in all browsers</a> at the time of this writing, so instead, folks across the web have been either curating both background and foreground colors separately or are required to pass their color choices through some preprocessor to output appropriate contrasting colors.</p>
<p>However, there’s been other advances in CSS that we might consider helping us achieve the features of <code>contrast-color()</code> today in all browsers.</p>
<p>The earliest attempt of getting this behavior that I can remember is <a href="https://css-tricks.com/css-variables-calc-rgb-enforcing-high-contrast-colors/">a post on CSS-Tricks</a> by <a href="https://joshuabader.com/">Josh Bader</a>. His approach required the person to break out the red, green, and blue channels of the color to then perform some math to get the contrasting color.</p>
<pre><code>:root {
  --red: 28;
  --green: 150;
  --blue: 130;

  --accessible-color: calc(
    (
      (
        (
          (var(--red) * 299) +
          (var(--green) * 587) +
          (var(--blue) * 114)
        ) / 1000
      ) - 128
    ) * -1000
  );
}

.button {
  color:
    rgb(
      var(--accessible-color),
      var(--accessible-color),
      var(--accessible-color)
    );
  background-color:
    rgb(
      var(--red),
      var(--green),
      var(--blue)
    );
}
</code></pre>
<div><h2>FYI</h2>
<p>Josh uses <code>--accessible-color</code> as the name here but the value set within this variable is a <em>number</em> and would be invalid as a color on its own.</p>
</div>
<p>The resulting <code>--accessible-color</code> is a number for each channel that would surpass the boundaries of the <code>rgb()</code> color function causing the resulting color to be either black or white. In other words, a color written as <code>rgb(-10, -10, -10)</code> is not invalid, it is black.</p>
<p>The problem here is that you’d need to break out your colors into separate channels for every color that you use. While this might be less decision making than before, it’s also <em>more</em> maintenance and naming as each color would need some channel identifier at the end, such as, <code>button-background-red</code>. This initial naming could even be confusing itself. While we intend for the value of this variable to be the number than represents the red channel for the button background, others looking at this could mistake this as the color to present a red button.</p>
<p></p>
<p>However, this approach did give me a new idea. In order to figure out the contrasting color for a given color, we need to get the numeric value for channels and manipulate them somehow. Luckily there’s a new feature within the CSS color specification that does exactly this: <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors">Relative Color Syntax</a>.</p>
<pre><code>body {
	background-color: rgb(from red r g b);
}
</code></pre>
<p>The result of the above code wouldn’t display anything special; the page would have a simple <code>red</code> background color. Where this gets interesting is the <code>r</code>, <code>g</code>, and <code>b</code> values. These are numbers that present the channels of red, green, and blue respectively for the given <code>red</code> color. This means we could use the given numbers to create a new color. For example, let’s make the <code>red</code> lighter using LCH.</p>
<pre><code>body {
	background-color: lch(from red calc(l + 20) c h);
}
</code></pre>
<p>Relative color syntax can break out the channels for many color spaces and many of the color functions we’ve been using for years include the <code>from</code> keyword in their syntax. This means than we can now easily add an alpha channel amount to a given color.</p>
<pre><code>body {
	background-color: rgb(from red r g b / .5);
}
</code></pre>
<p>Yep, we don’t need to explicitly use the <code>rgba</code> function here to include the alpha channel. This will add the <code>.5</code> opacity to the <code>red</code> for the background with ease.</p>
<div><h2>FYI</h2>
<p>You can learn more about all of this stuff in <a href="https://piccalil.li/blog/a-pragmatic-guide-to-modern-css-colours-part-one/">Kevin Powell’s recent series on color</a>.</p>
</div>
<p>So now that we have this system, we should be able to use the same calculation from Josh’s article within the color function. Buckle up, it’s a bit of a doosie!</p>
<pre><code>@property --channel {
  syntax: "*";
  inherits: false;
  initial-value: calc((((r * .299) + (g * .587) + (b * .114)) - 128) * -1000);
}

body {
	--color: black;
	--contrast-color: rgb(from var(--color)
    var(--channel)
    var(--channel)
    var(--channel)
  );
  background: var(--color);
  color: var(--contrast-color);
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ogLGaPO/bed68a6ba4fd7ff27638c08d7ffb03b0">Using @property to create reusable color-contrast channel</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Okay, this is a lot so let’s break it down. You’ll see I’m using the <code>@property</code> syntax to define a <code>calc()</code> function. I’m using <code>@property</code> to keep the gnarly formula away from the CSS declarations in use. In this function I’ve replicated the math happening in the original approach with some small changes.</p>
<p>For example, instead of dividing the result of the addition by 1000, we update the numbers to have been each divided by 1000. We make this a custom property so the declaration can be reused across channels without duplicating the mess of operations. From here, the use is similar to what Josh had done before except with the relative color syntax to pull out the individual channels automatically.</p>
<div><h2>FYI</h2>
<p>In my research for this article, I’ve discovered that the numbers used in this formula are <a href="https://gist.github.com/Myndex/e1025706436736166561d339fd667493">wrong</a> and should not be used in a sRGB context. Don’t worry, we’re about to see a different approach.</p>
</div>
<p>When I originally <a href="https://blog.damato.design/posts/css-only-contrast/">posted this back in June 2025</a>, <a href="https://matthewstrom.com/">Matt Ström-Awn</a> made some quick but important <a href="https://codepen.io/ilikescience/pen/azOpOqX">improvements</a>. He noted that WCAG contrast algorithm uses a color’s XYZ color space, specifically it’s Y value, to determine the level of acceptable contrast. This means that we can compute the contrasting color by using the <code>color()</code> function and setting the color space to XYZ. Then we force each channel to be computed based on the Y value. Here’s his implementation:</p>
<pre><code>body {
	color: color(from var(--color) xyz
		round(up, min(1, max(0, 0.18 - y)))
		round(up, min(1, max(0, 0.18 - y)))
		round(up, min(1, max(0, 0.18 - y)))
	);
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/vEKeVqE/a39b8ec7f9f6211b9e3a25edaad4e453">Using Matt Ström-Awn's approach for CSS color contrast</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>The <code>.18</code> number comes directly from the WCAG contrast ratio formula (L1 + 0.05) / (L2 + 0.05). To find the luminance where contrast against white equals contrast against black, you set them equal and solve for the luminance. Y = (√21 - 1) / 20 ≈ 0.1791.</p>
<div><h2>FYI</h2>
<p>This means that we could be more specific with the calculation using new CSS functions, but we’ll try to keep this simple. Here’s the more accurate calculation in CSS:</p>
<pre><code>body {
	/* ≈ 0.1791 */
	--y: calc((pow(21, .5) - 1) / 20); 
}
</code></pre>
</div>
<p>In writing this article, my research led me to a post by <a href="https://verou.me/">Lea Verou, Ph.D</a> who explores <a href="https://lea.verou.me/blog/2024/contrast-color/">a similar approach</a> about a year earlier where she says a good threshold for flipping to black text is when Y &gt; 0.36.</p>
<pre><code>body {
	color: color(from var(--color) xyz-d65
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
	);
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/NPrMjVJ/91ec5f695e9a458e58b9c0bb192a6579">Using Lea Verou's approach for CSS color contrast</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<div><h2>FYI</h2>
<p>This approach is taken from <a href="https://codepen.io/leaverou/pen/ExzVOME">Lea Verou’s own codepen</a>.</p>
<p>The additional <code>d65</code> is the <a href="https://en.wikipedia.org/wiki/Standard_illuminant">standard daylight illuminant</a> defined by <a href="https://cie.co.at/">CIE</a> in the <code>xyz</code> color space, representing simulated daylight at 6500K. In CSS <a href="https://developer.mozilla.org/en-US/docs/Glossary/Color_space#xyz">the <code>xyz</code> and <code>xyz-d65</code> are synonyms</a> and the <code>d65</code> can be safely omitted.</p>
<p>The use of <code>infinity</code> is probably overkill and could be replaced with a smaller number like <code>100</code> to ensure we push the result over the limit expected. <code>infinity</code> is used to show that this number isn’t some magic value but used to push the result in a specific direction.</p>
</div>
<p>There’s even further consideration for lower contrast mid luminances where fonts should be adjusted. Unfortunately, we cannot pull out the <code>y</code> from these functions to use in anything outside of the relative color syntax. Otherwise, we could use the <code>y</code> to affect things like <code>font-size</code> or <code>font-weight</code> to improve readability further.</p>
<p></p>
<p>One of the gotchas that I found while using these formulas is that they don’t account for the possibility of an alpha channel being included. Certainly, if the alpha channel number is significantly large, then the color isn’t really meant to contribute to the contrast algorithm. In other words, a color like <code>rgb(255 0 0 / .9)</code> is barely red and the text color that might appear on top should really be compared to another background color that appears truly underneath.</p>
<p>In the case where the alpha channel is not significant, I still want to ensure that the text does not receive the incoming alpha channel value. So, I recommend explicitly adding a <code>/ 1</code> to the end of the statement. This will cause the resulting foreground color to be opaque which is important for readability.</p>
<pre><code>body {
	color: color(from var(--color) xyz
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
		/ 1 /* Add for opaque color */
	);
}
</code></pre>
<p>As you might imagine, supplying values that aren’t a single color, like a gradient, will fail this computation. However, a color that is determined through <code>color-mix()</code> may also fail in the relative color syntax. This means that it’s best to provide simple color values into the function where possible to avoid the function from failing. When the function fails, the <code>color</code> statement will be invalid and a color you may have not been expecting might be rendered instead.</p>
<p>On the other hand, this doesn’t mean you couldn’t send the resulting color into a <code>color-mix()</code> to tint in a certain direction.</p>
<pre><code>body {
	background-color: var(--color);
	--contrast-color: color(from var(--color) xyz
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
		clamp(0, (.36 / y - 1) * infinity, 1)
		/ 1
	);
	color: color-mix(in srgb, var(--contrast-color), var(--color) 20%);
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/ByzwGNW/cdbba01d76ca438dbcc52a7cb428f4a0">Using color-mix() with CSS color contrast</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>In this example, we compute the contrast color with our latest formula and then mix that with the background slightly to produce the final foreground color. Importantly, this reduces the guarantee that the final foreground color will have sufficient contrast for certain given colors. So, choosing colors that have high saturation, and / or significant lightness would work best.</p>
<p>While we wait for <code>contrast-color()</code> to arrive in all browsers, these CSS-only approaches give us a practical way forward today. By leveraging relative color syntax and the XYZ color space, we can generate manageable contrasting colors without curating separate values. The formula isn’t perfect but for the majority of single-color backgrounds, it delivers what we’ve been hoping for: text that “just works”. As browser support continues to improve and these techniques become more refined, we’re one step closer to reducing the decision fatigue that comes with building for the web.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Accessible faux-nested interactive controls</title>
        <link>https://piccalil.li/blog/accessible-faux-nested-interactive-controls/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Eric Bailey]]></dc:creator>
        <pubDate>Thu, 15 Jan 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/accessible-faux-nested-interactive-controls/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>In web accessibility, <strong>a thing you absolutely cannot do is nest one interactive control inside another</strong>:</p>
<pre><code>&lt;!-- ❌ Never do this! --&gt;
&lt;button type="button"&gt;
  &lt;a href="/path/to/resource/"&gt;
    Save as favorite
  &lt;/a&gt;
&lt;/button&gt;
</code></pre>
<p>There are a few reasons not to do this, but the most important reasons is it can prevent people from using your service just by virtue of the way they use to interact with the web.</p>
<p>In spite of this fact, nested interactive controls are a pattern I see with regularity. I chalk this fact up to:</p>
<ol>
<li>A general lack of awareness or education about web accessibility,</li>
<li>A tendency to skip accessibility reviews and save fixes for some undetermined future state, and also</li>
<li>Newer web experiences becoming more “app-like.”</li>
</ol>
<p>By app-like, I mean things like <a href="https://developer.mozilla.org/en-US/blog/view-transitions-beginner-guide/">seamless transitions between pages</a>, less overall page content, and larger, touch-friendly interactive areas.</p>
<p>And app-like experiences on the web isn’t a bad thing! It’s more that <strong>when we borrow the <a href="https://www.interaction-design.org/literature/topics/affordances">affordances</a> of mobile apps we must also do so in a way that honors the conventions of the web platform</strong>.</p>
<h2>An example</h2>
<p>Here’s a UI pattern I was working on recently:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/list-component.png" alt="A simplified wireframe illustration of a list with four list items. Each list item contains a square representing an image placeholder, followed by large dark blue lines representing the list item’s primary action. Each list item’s background is a lighter blue color, used to communicate the entire list item is interactive and related to the primary action. Smaller gray lines are placed underneath each primary item, which suggests secondary static text. At the end of each list item is one to three pink squares, used to signify the list item’s secondary action(s). " /></p>
<p>It is a list of items, with each item containing both</p>
<ol>
<li>A primary action (blue), and</li>
<li>One or more secondary actions (pink).</li>
</ol>
<p>Here, the primary action was specified to <strong>apply to the entire list item row, minus the space the secondary actions took up</strong>.</p>
<p>This means you can click both the primary action itself, and all of its surrounding area, minus the space reserved for secondary actions.</p>
<p>The idea being it would make it easier and faster for people to activate the primary action. This ease of activation is also done without sacrificing the ability to use secondary actions.</p>
<p></p>
<h2><strong>Avoiding nesting interactive elements</strong></h2>
<p>We <strong>can’t nest interactive controls on the web</strong>. So, this underlying HTML structure for our component would be a non-starter:</p>
<pre><code>&lt;!-- ❌ Again, don't do this --&gt;
&lt;ul&gt;
  &lt;li&gt;
    &lt;a href="/path/to/resource/"&gt;
      Primary action
      &lt;button type="button"&gt;
        Secondary action
      &lt;/button&gt;
     &lt;/a&gt;
  &lt;/li&gt;
&lt;/ul&gt;
</code></pre>
<p>We can make this design work in such a way that <strong>both</strong> honors the designed intent and also does not nest interactive controls.</p>
<p>The secret to this approach? Adapting our very own <a href="https://piccalil.li/author/andy-bell">Andy Bell</a>’s <a href="https://piccalil.li/blog/create-a-semantic-break-out-button-to-make-an-entire-element-clickable/"><strong>semantic breakout button technique</strong></a>.</p>
<p>The gist of the technique is this:</p>
<ol>
<li>You can use <code>position: static</code>, plus an absolutely-positioned <code>::before</code> pseudo-element declaration to extend the clickable area of an interactive element to take up the entire height and width of the viewport.</li>
<li>You then use a <code>position: relative</code> declaration to “clamp” the clickable area to a parent element.</li>
<li>Finally, you increase the <code>z-index</code> value of other interactive elements to ensure they “float” over the rest of the interactive area and are clickable.</li>
</ol>
<p>Here’s a short comic of how it works, if you’re more of a visually-oriented thinker:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/accessible-faux-controls-comic.png" alt="A five panel comic. The first panel has a caption that reads, “1. A link is added.” It shows hot pink text that reads, “Primary action” floating above a blank white background. The second panel has a caption that reads, “2. The semantic breakout button technique is applied.” It shows a light pink background that takes over the entire white area. Four darker pink arrows extend from the top, bottom, right, and left-hand sides of the text. The third panel’s caption is, “3. The breakout area is constrained.” It depicts a narrow rectangle with rounded corners, a purple border, and a label that reads “Container”. The primary action text has been moved inside the rectangle, towards its left-hand side. The light pink background is constrained by the rectangle’s boundaries, re-revealing the white background. The fourth panel’s label is “4. A secondary action is added.” It shows a small purple button added to the inside of the rectangle, placed towards its right-hand side. The button has a label that reads, “Secondary action”. The fifth and final panel has a label of “5. Applying z-index ensures the secondary action is clickable”. It shows the secondary action button floating above the container rectangle. The rectangle has been rotated to an isometric view, to better show how the secondary button is on a higher plane." /></p>
<p>We can then build from this, combining it with things like <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within"><code>:focus-within</code></a> and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible"><code>:focus-visible</code></a> to <strong>create the appearance of nested interactive controls without actually having to do so in the DOM</strong>.</p>
<h2>Putting it into practice</h2>
<p>Without further ado, here’s a CodePen of the final result:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/dPGJZeO">Accessible faux-nested interactive controls</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Now, let’s break it down:</p>
<h3>Structure</h3>
<h4>Container</h4>
<p>The total list of items is contained within an unordered list. Each item within the list is contained with a <code>li</code> element. This enables assistive technology to:</p>
<ul>
<li>Know that it is a list,</li>
<li>Enumerate how many items are in the list, and</li>
<li>Announce which list item is currently being read through.</li>
</ul>
<h4>Primary content area</h4>
<p>This is a <code>div</code> placed within the parent <code>li</code> element, and it is used to house the main action, as well as supplemental content. And placing <code>div</code> elements within a <code>li</code> is totally a thing you can do! <a href="https://shkspr.mobi/blog/2025/12/the-web-runs-on-tolerance/">HTML is pretty flexible</a>, and it’s <a href="https://validator.nu/">valid markup</a>.</p>
<h4>Secondary content area</h4>
<p>This is another <code>div</code> placed within the parent <code>li</code> element. It is used to house the secondary action(s).</p>
<h4>Leading visual</h4>
<p>This is a child element of the primary content area, and provides an area where we can insert an icon or graphic to help with quick visual scanning. In our example, it provides a photo of the lunch special you can order.</p>
<h4>Primary action</h4>
<p>This is also a child element of the primary content area. In our example, the primary action is a link that allows you to read up on more detail about the lunch special.</p>
<p><strong>This is the main thing we want people to click on</strong>. It will be targeted in CSS to extend its interactive area to the boundaries of the parent <code>li</code> element, using the breakout technique discussed previously.</p>
<p>We also use a heading element to wrap the text of the primary action. This allows people who use screen readers to <a href="https://webaim.org/projects/screenreadersurvey10/#finding">quickly scan the page to know its overall makeup</a> when navigating by heading.</p>
<p>From there, people can then <a href="https://dequeuniversity.com/screenreaders/nvda-keyboard-shortcuts#nvda-the_basics">use other navigation techniques</a> to dial in when they want to learn more about the content the heading introduces.</p>
<h4>Secondary actions</h4>
<p>These are children of the secondary content area.</p>
<p>More than one secondary action can be added to the secondary content area, provided the actions <strong>are not</strong> nested inside one another in the DOM.</p>
<p>There is also no upper limit to the number of secondary actions you can use, either. In our example, the secondary actions are two <code>buttons</code> that allow you to:</p>
<ol>
<li>Favorite an item in the list, and also</li>
<li>Quickly add an item to your shopping cart.</li>
</ol>
<p>It is also good practice to disambiguate the accessible names of your secondary actions. I like a “verb noun” pattern, so for our example it’s “Favorite: Sashimi Lunch” and “Add to cart: Salmon and Avocado Maki.”</p>
<h3>Styling</h3>
<h4>Grids</h4>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/grid">CSS grid</a> does the heavy lifting for placing content. We’re using <a href="https://thoughtbot.com/blog/concise-media-queries-with-css-grid">named grid areas</a> to both:</p>
<ol>
<li>Place items within the primary and secondary content areas, and then</li>
<li>Lay content out within the primary content area.</li>
</ol>
<p>Named grid areas is a technique that I’ve found helpful for future maintenance efforts.</p>
<p>Other people working with the component may be less familiar with both CSS and grid-based declarations. Because of this, a visual arrangement of the layout in code that uses easy-to-understand <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/grid-area">grid area names</a> may make it less confusing to parse — especially when your design needs to be responsive.</p>
<p>The use of named grid areas also <strong>potentially lowers the chance someone does something unintended</strong>. This is really important for a component like this, where we rely on <code>z-index</code> to ensure secondary content remains interactable.</p>
<h4>Container queries</h4>
<p>Speaking of making things responsive, we’re using <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries">container queries</a> to adjust the layout so it adapts to smaller horizontal surface area without <a href="https://www.w3.org/WAI/WCAG21/Understanding/reflow.html">creating horizontal overflow</a>.</p>
<p>The cool bit about using container queries instead of more traditional media queries is that it creates more self-contained and self-sufficient components. Here, we know that <strong>the component will adapt to whatever container it is placed in</strong>, regardless of the container’s available horizontal width.</p>
<h4>Primary action click area</h4>
<p>The semantic breakout styling is applied to <code>.list-item-primary-action</code> — the primary action.</p>
<p>The clickable area is then constrained by <code>.list-item</code>, the parent list item. This means that <strong>the actual interactive area is the same size as the entire list item’s computed size</strong>.</p>
<h4>Secondary action ‘clickability’</h4>
<p>The secondary actions are elevated above the primary action via a <code>z-index</code> declaration to ensure clicks and taps can successfully be intercepted.</p>
<p>We use <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/calc"><code>calc()</code></a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/--*">Custom Properties</a>, and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:where"><code>:where()</code></a> to create some defensive design here:</p>
<ul>
<li>The <code>z-index</code> of secondary actions is set to <strong>always be one number higher</strong> than the <code>z-index</code> of its parent list item.</li>
<li>The parent list item‘s <code>z-index</code> is, in turn, scoped to the component’s parent class.</li>
</ul>
<p>This approach helps to prevent <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context">stacking context</a> accidents by ensuring that <strong>secondary actions will always be clickable regardless of what <code>z-index</code> value is used</strong> — or updated to use.</p>
<p>We then use <code>:where()</code> to target every possible interactive element you can declare in HTML and also apply the incremented <code>z-index</code>  treatment. This helps ensure this component is future-proof, which is important given both:</p>
<ul>
<li>The relative rarity of the semantic breakout technique, and</li>
<li>The need to ensure new secondary actions stay intractable.</li>
</ul>
<h2>A tangent about accessible name length</h2>
<p>An accessible name is the text value supplied to an interactive element. The preferred way to do this is to use a string in between the opening and closing tags of a HTML element:</p>
<pre><code>&lt;!-- This button has an accessible name of "Print recipe" --&gt;
&lt;button type="button"&gt;
  Print recipe
&lt;/button&gt;
</code></pre>
<p>It is considered good practice to have your accessible names be <strong>both concise and descriptive</strong>. So, an accessible name of “Print recipe” is far more desirable than something like, “Send this recipe to your printer so you can take it into your kitchen.”</p>
<p>Interactive elements that contain multiple child elements with <strong>a lot of text content can inadvertently have a really long accessible name</strong>. An example of this is a block-level anchor link, say for a card component:</p>
<pre><code>&lt;a class="card-link" href="/path/to/resource/"&gt;
  &lt;div class="card"&gt;
    &lt;img alt="A Nintendo Switch 2 placed on a Kirby-shaped pillow." class="card-hero" src="/path/to/image.png" /&gt;
    &lt;h3 class="card-title"&gt;
      The best gift ideas for new college students 
    &lt;/h3&gt;
    &lt;p class="card-description"&gt;
      Whether it is a new game console or a set of smart appliances, these items can make great gifts for your special someone!
    &lt;/p&gt;
    &lt;ul class="card-tags"&gt;
      &lt;li class="card-tag"&gt;
        Trending
      &lt;/li&gt;
      &lt;li class="card-tag"&gt;
        Hot
      &lt;/li&gt;
      &lt;li class="card-tag"&gt;
        Gift Ideas
      &lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/a&gt;
</code></pre>
<p>The entire card component is wrapped in an anchor element. Because of this, all its text content gets “flattened” and turned into one long accessible name.</p>
<p>This flattening will create an assistive technology announcement along the lines of:</p>
<blockquote>
<p>A Nintendo Switch 2 placed on a Kirby-shaped pillow, graphic. The best gift ideas for new college students, heading level 3. Whether it is a new game console or a set of smart appliances, these items can make great gifts for your special someone! List, bullet Trending, bullet Hot Gift Ideas. Link.</p>
</blockquote>
<p>This announcement is a lot of information to parse if you’re using a screen reader, which is tedious at best and confusing at worst. So, why do I bring this up here?</p>
<p>Well, the semantic breakout button technique also works wonderfully in situations like this, where <strong>we want a more concise accessible name without sacrificing the increased click area size</strong>. This helps guarantee an <a href="https://www.smashingmagazine.com/2020/05/equivalent-experiences-part1/">equivalency of experience</a>, in that the card titles can be quickly skimmed to see if they’re of interest — both visually and not.</p>
<p>For our card example, we could move the anchor element inside of the card title, then extend it‘a interactive area out to cover the whole card using an application of the semantic breakout technique that targets the parent <code>.card</code> class.</p>
<p>The entire card would remain clickable, but this update would create <strong>a far less verbose assistive technology announcement</strong> for the primary link, which will be something along the lines of:</p>
<blockquote>
<p>The best gift ideas for new college students. Link.</p>
</blockquote>
<p>The other bit worth knowing is that use of this technique <strong>does not impede the ability of a person using a screen reader</strong> to explore the rest of the card’s content.</p>
<p>Using the semantic breakout technique makes it so the card content will be read out the same expected way it reads other static DOM content. That’s a good thing!</p>
<p></p>
<h2>Another tangent about usability considerations</h2>
<p>Faux-nested interactive controls run the risk of accidental activation of the wrong thing, so exercise caution when using them.</p>
<p>Consider things like:</p>
<ul>
<li>In-the-moment distractions,</li>
<li>Layout shifting,</li>
<li><a href="https://webaim.org/articles/visual/lowvision">Low vision</a>,</li>
<li><a href="https://webaim.org/articles/motor/motordisabilities">Hand tremors</a>,</li>
<li>Assistive technology such as <a href="https://webaim.org/articles/motor/assistive#eyetracking">eye-tracking</a>,</li>
<li>etc.</li>
</ul>
<p>Accidentally clicking the wrong thing just by virtue of circumstance isn’t great for anyone involved.</p>
<p>Because of this, caution should be used when deciding to use this technique. <strong>Be especially careful when it comes to <a href="https://primer.style/product/components/confirmation-dialog/guidelines/#usage">irrevocable or destructive actions</a></strong>.</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/launch-rocket-eject-fuel-padded.png" alt="A circular button nested inside of a longer, more rectangular button. The circular button has a red background and has a label that reads, “Eject fuel”. The gray button has a narrow green indicator light and a label that reads, “Launch rocket.”" /></p>
<p>It’s considered good practice to provide a secondary “Are you sure?” confirmation step for this situation, regardless of how the UI looks.</p>
<h2>A third tangent about progressive enhancement</h2>
<p>The only JavaScript we’re using in the CodePen demo is a small piece of logic that gives you feedback that you successfully activated a primary action.</p>
<p><a href="https://cydstumpel.nl/why-we-teach-our-students-progressive-enhancement/">We’re still left with something functional</a> when <a href="https://piccalil.li/blog/a-handful-of-reasons-javascript-wont-be-available/">JavaScript</a>, images, or styles fail to load: A list of items that contain links.</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/accessible-faux-controls-no-styles.png" alt="The CodePen example with styles disabled, using browser-default fallback styling. It still renders as a list of five items with a primary-action-as-title, price, and secondary action buttons." /></p>
<p>This is pretty helpful, especially in <a href="https://sparkbox.com/foundry/helene_and_mobile_web_performance">less-than-ideal circumstances</a>.</p>
<h2>Wrapping up</h2>
<p>Modern CSS lets you have it all: resilient, adaptable, fault-tolerant experiences that recreate the affordances of contemporary app-like experiences without sacrificing accessibility.</p>
<p>Considered and thoughtful applications of CSS — like Andy Bell’s <a href="https://piccalil.li/blog/create-a-semantic-break-out-button-to-make-an-entire-element-clickable/">semantic breakout button technique</a> — can mesh harmoniously with newer features to create all sorts of new and exciting experiences.</p>
        
        ]]></description>
        
      </item>
    
    </channel>
  </rss>
