<?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>Tue, 07 Apr 2026 01:02:02 GMT</pubDate>
      <lastBuildDate>Tue, 07 Apr 2026 01:02:02 GMT</lastBuildDate>

      
      <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>
    
      <item>
        <title>Why are my view transitions blinking?</title>
        <link>https://piccalil.li/blog/why-are-my-view-transitions-blinking/?ref=css-category-rss-feed</link>
        <dc:creator><![CDATA[Miguel Pimentel]]></dc:creator>
        <pubDate>Thu, 11 Dec 2025 11:54:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/why-are-my-view-transitions-blinking/?ref=css-category-rss-feed</guid>
        <description><![CDATA[<p>View transitions promise smooth, native animations that make your web app feel polished and professional. The browser handles the heavy lifting for you by taking snapshots, creating pseudo-elements, and animating between states.</p>
<p>When you call <code>document.startViewTransition()</code>, the browser creates pseudo-elements (<code>::view-transition-old()</code> and <code>::view-transition-new()</code>) that represent the before and after states of your content. The browser then animates between these snapshots, creating smooth visual transitions. You can read more on that <a href="https://piccalil.li/blog/start-implementing-view-transitions-on-your-websites-today/#anatomy-of-a-view-transition">here</a>.</p>
<p>I spent more time than I'd like to admit debugging view transitions blinks before understanding what was happening. The API seemed straightforward enough, but my transitions kept flashing. Here's what I learned, so you don't have to repeat my mistakes.</p>
<h2>Beware, it blinks!</h2>
<p>You've set up view transitions, you click, and… blink. Instead of a smooth morph between states, you get a brief flicker where content disappears and reappears abruptly. Sometimes the transition completes successfully, but then the old content briefly flashes before the new content settles. The flash can be subtle or obvious, but once you notice it, <em>you can't unsee it</em>. This typically occurs in tab interfaces, modals, and other components where content is shown or hidden dynamically.</p>
<p>The blink can also happen when the transition completes instantly rather than animating smoothly. You might see the old content vanish, followed by the new content appearing without any intermediate animation frames. What you expected was a smooth morph; what you got was an abrupt swap.</p>
<h3>But why does it blink?</h3>
<p>The browser needs to correlate elements between the old and new states using the <code>view-transition-name</code> CSS property. When an element in the old DOM state has the same <code>view-transition-name</code> as an element in the new DOM state, the browser creates a smooth animation between them.</p>
<p>The blink occurs when this correlation fails, resulting in the browser falling back to a cross-fade animation. Common causes include:</p>
<ul>
<li>The old element doesn't have a <code>view-transition-name</code> assigned before the transition starts.</li>
<li>Multiple elements share the same <code>view-transition-name</code> (each must be unique.)</li>
<li>The element is hidden via CSS (such as <code>opacity: 0</code> and <code>display: none</code>) rather than being removed from the DOM.</li>
<li>The <code>view-transition-name</code> is removed or changed at the wrong time.</li>
</ul>
<p>When correlation fails, the browser has no reference point for creating the transition between states. Instead of smoothly morphing between the correlated element’s state, it falls back to a default cross-fade of the entire viewport. This appears as a blink because no smooth element-to-element morphing occurs; the browser just swaps between the two viewport states.</p>
<p></p>
<h2>A tabbed interface example</h2>
<p>Tabbed interfaces commonly suffer from the blink because they typically use CSS to toggle visibility between panels rather than manipulating the DOM directly. This pattern breaks the element correlation that view transitions require.</p>
<h3>The broken version</h3>
<p>Let me show you exactly how I broke it first. Learning from mistakes is more instructive than pretending I got everything right on the first try.</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/emJYpJM">Tabs - View Transitions - Old</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 problematic pattern uses <code>position: absolute</code> for all panels and toggles the <code>active</code> class:</p>
<pre><code>function switchToTab(targetTabId) {
  withViewTransition(() =&gt; {
    // Remove active from all panels
    tabPanels.forEach(panel =&gt; {
      panel.classList.remove('active');
      panel.style.viewTransitionName = 'none'; // Trying to be clever... (spoiler: doesn't work)
    });

    // Add active to target panel
    const targetPanel = document.getElementById(targetTabId);
    targetPanel.classList.add('active');
    targetPanel.style.viewTransitionName = 'active-tab'; // Only new state identified
  });
}
</code></pre>
<p>With CSS like:</p>
<pre><code>.tab-panel {
  position: absolute;
  opacity: 0; /* All panels exist, just hidden */
  pointer-events: none;
}

.tab-panel.active {
  opacity: 1; /* Only changes visibility */
  pointer-events: auto;
}
</code></pre>
<p>This approach fails because:</p>
<ol>
<li>All panels exist in the DOM simultaneously with only visibility differences.</li>
<li>Only the new active panel receives a <code>view-transition-name</code>, leaving the browser unable to identify which old panel to transition from.</li>
<li>Setting <code>viewTransitionName = 'none'</code> explicitly breaks the correlation. Critically, attempting to remove the transition name inside the callback is too late, as the browser's snapshot of the "old" state has already been taken.</li>
</ol>
<p>When the transition runs, the browser cannot determine which element in the old state corresponds to which element in the new state. The result is an instant swap rather than a smooth animation.</p>
<h3>The working version</h3>
<p>Here's the pattern that actually works:</p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/QwyWjNv">Tabs - View Transitions</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 working solution performs true DOM manipulation:</p>
<pre><code>function switchToTab(targetTabId) {
  const currentPanel = tabContent.querySelector('.tab-panel');
  const currentButton = document.querySelector('.tab-button.active');
  const targetButton = document.querySelector(`[data-tab="${targetTabId}"]`);

if (hasViewTransitions &amp;&amp; currentPanel) {
    // KEY: Assign view-transition-name to current panel BEFORE transition
    currentPanel.style.setProperty('view-transition-name', 'active-tab');

    const transition = document.startViewTransition(() =&gt; {
      // Update button states (animates the indicator)
      currentButton.classList.remove('active');
      targetButton.classList.add('active');

      // KEY: True DOM manipulation - remove old, create new
      tabContent.innerHTML = ''; // Removes all child elements
      const newPanel = createTabPanel(targetTabId);
      newPanel.style.setProperty('view-transition-name', 'active-tab');
      tabContent.appendChild(newPanel);
    });

    // KEY: Clean up after transition completes
    transition.finished.then(() =&gt; {
      const panel = tabContent.querySelector('.tab-panel');
      if (panel) {
        panel.style.removeProperty('view-transition-name');
      }
    });
  }
}
</code></pre>
<p>This approach succeeds because:</p>
<ol>
<li>The current panel is identified with <code>view-transition-name</code> <em>before</em> the transition callback executes</li>
<li>The old panel is completely removed from the DOM</li>
<li>A new panel is created and assigned the same <code>view-transition-name</code></li>
<li>The browser can now correlate the old and new elements, creating a smooth animation</li>
<li>Cleanup removes the transition name to prevent conflicts</li>
</ol>
<p>The tab indicator uses a separate <code>view-transition-name</code> for independent animation:</p>
<pre><code>.tab-button.active::after {
  content: '';
  position: absolute;
  bottom: -1px;
  left: 0;
  width: 100%;
  height: 3px;
  background: #007bff;
  view-transition-name: tab-indicator;
}
</code></pre>
<p>This component separation allows the indicator to slide smoothly while the content transitions independently. Note that the indicator uses a <em>static</em> transition name in CSS because it's always present, just moving between buttons. The content uses <em>dynamic</em> naming in JavaScript because panels are created and destroyed with each transition.</p>
<p></p>
<h2>Universal principles for smooth transitions</h2>
<p>The tabbed interface example demonstrate a key pattern: it solves the core problem by creating and destroying elements. This strategy works because it gives the browser clear before-and-after states. The following principles extract what makes this pattern successful and apply them to any component, regardless of whether you are swapping elements or dynamically managing a single persistent element.</p>
<h3>Don't mix the old with the new</h3>
<p>Each <code>view-transition-name</code> must be unique per page at any given time. Having multiple elements with the same transition name creates ambiguity, and the browser cannot determine which elements to correlate.</p>
<p>When transitioning between states, ensure only one element has a specific <code>view-transition-name</code> at a time. This is why dynamic assignment works because you assign the name to the old element, perform the transition, assign it to the new element, then clean up.</p>
<p>Silent failure occurs when duplicate names exist. The browser doesn't throw an error, but the transition doesn't work as expected.</p>
<h3>Get your hands into that DOM</h3>
<p>Visibility-based approaches using <code>opacity</code>, <code>display: none</code>, or <code>visibility: hidden</code> don't create distinct old and new states. The same element with the same transition name exists before and after, giving the browser nothing to animate between.</p>
<p>True DOM manipulation means:</p>
<ul>
<li>Removing the old element completely (<code>element.remove()</code> or <code>container.innerHTML = ''</code>, which removes all child elements)</li>
<li>Creating and inserting a new element (<code>container.appendChild(newElement)</code>)</li>
<li>Allowing the browser to see two distinct elements with the same transition name in different states</li>
</ul>
<p>If your component architecture requires keeping elements in the DOM (for example, for performance reasons with many panels), consider using view transitions only for the active content area and handling inactive states separately.</p>
<div><h2>FYI</h2>
<p>When removing elements from the DOM, ensure screen reader announcements are appropriate. <a href="https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/">Sara Soueidan has a fantastic, deep dive here for you</a>.</p>
</div>
<h3>When to dynamically name your children</h3>
<p>Static <code>view-transition-name</code> assignments in CSS create persistent identifiers that can cause conflicts. Instead, assign transition names programmatically just before transitions.</p>
<p>For elements that participate in transitions:</p>
<pre><code>// Before transition
element.style.setProperty('view-transition-name', 'unique-identifier');

// After transition completes
transition.finished.then(() =&gt; {
  element.style.removeProperty('view-transition-name');
});
</code></pre>
<p>For elements that always transition (like indicators or badges), static CSS naming is appropriate:</p>
<pre><code>.notification-badge {
  view-transition-name: notification-badge;
}
</code></pre>
<p>The distinction is whether the element always exists and always transitions (static naming) or appears and disappears conditionally (dynamic naming).</p>
<p>Component separation extends this principle. When a component has multiple independently animating parts (like tabs with indicators), assign different transition names:</p>
<pre><code>.tab-indicator {
  view-transition-name: tab-indicator;
}

.tab-content {
  /* Assigned dynamically in JavaScript */
}
</code></pre>
<p>This allows each part to animate independently without interfering with the other.</p>
<h2>A Practical implementation checklist</h2>
<p>A systematic approach to view transitions prevents common mistakes and ensures reliable behaviour. Let's break it down so we can learn about what happens before, during, and after transitions.</p>
<h3>Before a transition</h3>
<p><strong>Identify the departing element.</strong> Find the current element that will be replaced. Store a reference to it if you'll need to access it within the transition callback.</p>
<pre><code>const currentElement = container.querySelector('.active-content');
</code></pre>
<p><strong>Assign <code>view-transition-name</code>.</strong> Give the current element a unique <code>view-transition-name</code>. This must happen <em>before</em> calling <code>startViewTransition()</code>. That is, before the function is invoked, not just before the callback executes.</p>
<pre><code>if (currentElement) {
  currentElement.style.setProperty('view-transition-name', 'content-transition');
}

// The view-transition-name is assigned BEFORE this line executes
const transition = document.startViewTransition(() =&gt; {
  // DOM manipulation happens here
});
</code></pre>
<p><strong>Verify uniqueness.</strong> Ensure no other element on the page currently has this <code>view-transition-name</code>. Use DevTools to check for duplicate names if transitions aren't working.</p>
<p><strong>Check accessibility state.</strong> Ensure the current element has appropriate ARIA attributes that will be replicated in the new element (<code>role</code>, <code>aria-labelledby</code>, etc.).</p>
<h3>During a transition</h3>
<p><strong>Perform minimal DOM operations.</strong> Keep the transition callback focused. Fetch data and prepare content <em>before</em> the transition, not within it.</p>
<pre><code>// Good: prepare first
const newContent = await fetchContent();
document.startViewTransition(() =&gt; {
  updateDOM(newContent); // Fast operation
});

// Bad: slow operations block transition
document.startViewTransition(async () =&gt; {
  const newContent = await fetchContent(); // Blocks!
  updateDOM(newContent);
});
</code></pre>
<p><strong>Assign the <code>view-transition-name</code> to the new element.</strong> After creating/revealing the new element, assign it the same <code>view-transition-name</code> as the old element.</p>
<pre><code>document.startViewTransition(() =&gt; {
  container.innerHTML = '';
  const newElement = createNewElement();
  newElement.style.setProperty('view-transition-name', 'content-transition');
  container.appendChild(newElement);
});
</code></pre>
<p><strong>Maintain focus context.</strong> If the old element had focus, ensure the new element receives focus after the transition. Use <code>element.focus()</code> at the end of your transition callback.</p>
<pre><code>document.startViewTransition(() =&gt; {
  updateDOM();
  newElement.focus(); // Maintain keyboard navigation context
});
</code></pre>
<h3>After a transition</h3>
<p><strong>Clean up <code>view-transition-name</code> values.</strong> Remove dynamic <code>view-transition-name</code> values once the animation completes. This prevents naming conflicts in subsequent transitions.</p>
<pre><code>transition.finished.then(() =&gt; {
  element.style.removeProperty('view-transition-name');
});
</code></pre>
<p><strong>Verify DOM state.</strong> Ensure your new state is fully established. Check that event listeners are attached, ARIA attributes are correct, and the component is interactive.</p>
<p><strong>Handle focus restoration.</strong> For components that overlay content or temporarily block interaction, ensure focus returns to the appropriate element when they close or hide.</p>
<pre><code>const triggerElement = document.activeElement;

// … later when closing
transition.finished.then(() =&gt; {
  triggerElement.focus();
});
</code></pre>
<p><strong>Update screen reader context.</strong> If the content change is significant and outside the user's current focus, use <code>aria-live</code> to announce it.</p>
<pre><code>transition.finished.then(() =&gt; {
  announcer.textContent = `Switched to ${newTabLabel}`;
});
</code></pre>
<h3>Common and silent errors</h3>
<p>View transitions fail silently, which makes debugging frustrating. The browser doesn't throw errors — the animation just… doesn't happen.</p>
<p><strong>Duplicate <code>view-transition-name</code> values.</strong> Multiple elements with the same <code>view-transition-name</code>. The broken tabs example demonstrated this issue well for us. All panels existed simultaneously with potential naming conflicts. The solution was to ensure unique names or use dynamic assignment.</p>
<p><strong>Timing issues.</strong> Assigning <code>view-transition-name</code> values after the transition starts. The browser captures state at the moment <code>startViewTransition()</code> is called. The solution is to assign names before the function is invoked.</p>
<p><strong>Persistent names.</strong> This occurs when <code>view-transition-name</code> values are not cleaned up after use. When transition names remain attached to hidden or inactive elements, they can interfere with subsequent animations. The solution is to remove transition names in the <code>finished</code> callback.</p>
<p><strong><a href="https://piccalil.li/blog/a-primer-on-the-cascade-and-specificity/">CSS specificity</a> conflicts.</strong> Inline styles might be overridden by CSS selectors. The solution is to use <code>setProperty()</code> with inline styles or increase CSS specificity.</p>
<p><strong>Browser support.</strong> View transitions aren't supported in all browsers (yet). Always <a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API#css.at-rules.view-transition">check for support</a> and provide fallbacks and/or lean into <a href="https://piccalil.li/blog/its-about-time-i-tried-to-explain-what-progressive-enhancement-actually-is/">progressive enhancement</a>:</p>
<pre><code>function withViewTransition(callback) {
  if ('startViewTransition' in document) {
    return document.startViewTransition(callback);
  } else {
    callback();
    return {
      finished: Promise.resolve(),
      ready: Promise.resolve(),
      updateCallbackDone: Promise.resolve()
    };
  }
}
</code></pre>
<p><strong>Memory leaks.</strong> Creating elements without removing them or failing to clean up event listeners. Even though JavaScript is <a href="https://piccalil.li/javascript-for-everyone/lessons/25">good at garbage collection</a>, a good practice is to ensure complete element removal and use <code>AbortController</code> for event listener cleanup.</p>
<h2>Wrapping up</h2>
<p>So what's the takeaway from all this blinking? The browser needs clear signposts: "this element <em>was</em> here, and <em>now</em> it's here." Miss those signposts, and you get the dreaded blink.</p>
<p>The view transitions blink stems from the browser's inability to correlate elements between old and new states. This happens when <code>view-transition-name</code> assignments are missing, duplicated, or applied at the wrong time.</p>
<p>This debugging experience taught me the core lesson: <strong>the browser needs distinct before-and-after states</strong>. Everything else is implementation details.</p>
<p>The solution is systematic:</p>
<ol>
<li>Explicitly identify old elements before transitions</li>
<li>Perform true DOM manipulation rather than visibility toggles</li>
<li>Assign matching <code>view-transition-name</code> values to new elements</li>
<li>Clean up names after transitions complete</li>
<li>Maintain accessibility throughout the transition lifecycle</li>
</ol>
<p>These patterns apply broadly across components. Whether building tabs, modals, carousels, or custom interfaces, the principles remain the same: clear element identity, proper DOM manipulation, and careful lifecycle management.</p>
<p>View transitions offer powerful capabilities for creating smooth, professional animations. Understanding element correlation and following these patterns ensures that capability is realised without the frustrating blink.</p>
        
        ]]></description>
        
      </item>
    
    </channel>
  </rss>
