<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: richi.codes</title>
    <description>The latest articles on DEV Community by richi.codes (@richicodes).</description>
    <link>https://dev.to/richicodes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F10997%2Fdibuix_rixi.jpg</url>
      <title>DEV Community: richi.codes</title>
      <link>https://dev.to/richicodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/richicodes"/>
    <language>en</language>
    <item>
      <title>CSS alpha(): one brand color, every transparency</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:30:43 +0000</pubDate>
      <link>https://dev.to/richicodes/css-alpha-one-brand-color-every-transparency-283d</link>
      <guid>https://dev.to/richicodes/css-alpha-one-brand-color-every-transparency-283d</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-11-02-css-alpha-one-brand-color-every-transparency" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CSS finally has a function that does exactly one thing: take a color and change how transparent it is. &lt;code&gt;alpha(from var(--brand) / 40%)&lt;/code&gt; is your brand color at 40% opacity, whatever format &lt;code&gt;--brand&lt;/code&gt; was written in. It works in Chrome 152, Firefox 155 and Safari 27, and it has been Baseline since September 14, 2026. In the demo above, a single variable feeds a glass card, a progress bar, the borders and a six-step scale. Pick a color, drag the slider, and the whole thing follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this needed its own function
&lt;/h2&gt;

&lt;p&gt;If you keep a design system with tokens, you know the problem. You define &lt;code&gt;--brand&lt;/code&gt; once, and then you need the same color at 10% for a background, 30% for a border, 50% for a glow. For years the options were keeping a separate hand-written &lt;code&gt;rgba()&lt;/code&gt; for each one (and forgetting to update them when the brand changes), or reaching for heavier tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Relative color syntax&lt;/strong&gt; (&lt;code&gt;rgb(from var(--brand) r g b / 40%)&lt;/code&gt;) works, but you have to choose a color function and spell out every channel. With &lt;code&gt;rgb()&lt;/code&gt;, &lt;code&gt;hsl()&lt;/code&gt; or &lt;code&gt;hwb()&lt;/code&gt; the output is serialized as &lt;code&gt;color(srgb …)&lt;/code&gt;, according to MDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;color-mix()&lt;/code&gt; with &lt;code&gt;transparent&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;color-mix(in oklab, var(--brand) 40%, transparent)&lt;/code&gt;) also works, but now you're picking an interpolation space just to make something see-through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;alpha()&lt;/code&gt; skips both decisions. According to the spec, it refers to an origin color and "only change[s] the alpha channel". The color components stay as they are, and "the result of this function is in the color space of the origin color".&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The grammar is short. This is the one line in CSS Color Module Level 5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;color&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="o"&gt;[&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;alpha-value&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nt"&gt;none&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;After &lt;code&gt;from&lt;/code&gt; goes any color: a hex value, &lt;code&gt;oklch()&lt;/code&gt;, a named color, or &lt;code&gt;var(--brand)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After the slash goes the new alpha. Inside it, the keyword &lt;code&gt;alpha&lt;/code&gt; means the origin color's current alpha as a number (1.0 is 100%), so you can work relative to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the pattern the demo uses, minus the slider plumbing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;62%&lt;/span&gt; &lt;span class="m"&gt;0.24&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.glass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* same color, fixed opacity */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;16%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;57%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt; &lt;span class="m"&gt;50px&lt;/span&gt; &lt;span class="m"&gt;-18px&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;47%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.ghost&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* half of whatever alpha the origin already had */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike &lt;code&gt;oklch(from …)&lt;/code&gt; or &lt;code&gt;rgb(from …)&lt;/code&gt;, &lt;code&gt;alpha()&lt;/code&gt; doesn't expose the channel keywords (&lt;code&gt;l&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt;, &lt;code&gt;h&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;, &lt;code&gt;g&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;). MDN is explicit that it's "solely concerned with the alpha channel". Use it when that's all you want to change, and keep relative color syntax for when you want a lighter or more saturated variant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the color space difference
&lt;/h2&gt;

&lt;p&gt;The bottom panel of the demo runs &lt;code&gt;getComputedStyle()&lt;/code&gt; on the same color written three ways. In Chromium 153, with &lt;code&gt;oklch(62% 0.24 300)&lt;/code&gt; as the origin and 42% opacity, I get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;42%&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                     &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nt"&gt;oklch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;62&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;24&lt;/span&gt; &lt;span class="err"&gt;300&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;42&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nt"&gt;rgb&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="nt"&gt;r&lt;/span&gt; &lt;span class="nt"&gt;g&lt;/span&gt; &lt;span class="nt"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;42%&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                 &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nt"&gt;color&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;srgb&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;626103&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;325981&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;99362&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;42&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;oklab&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="m"&gt;42%&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nt"&gt;oklab&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;62&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;12&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;207846&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;42&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On screen, all three look the same. But only &lt;code&gt;alpha()&lt;/code&gt; gives you back what you put in: an OKLCh color stays OKLCh. That matters if you inspect tokens in DevTools or feed computed values into other color math. With a hex origin, the same call in Chromium returns &lt;code&gt;rgba(255, 82, 0, 0.4)&lt;/code&gt;, which is still the sRGB the hex value came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Support:&lt;/strong&gt; Chrome and Edge 152, Firefox 155, Safari 27 (desktop and iOS), according to MDN's browser-compat-data. That makes it Baseline "newly available" from September 14, 2026, so a lot of browsers in use today still won't understand it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback: the classic trick doesn't work here.&lt;/strong&gt; Normally you write a fallback declaration first, and if the browser can't parse the second one, it ignores it. But &lt;code&gt;alpha(from var(--brand) / 16%)&lt;/code&gt; contains &lt;code&gt;var()&lt;/code&gt;, so the browser can't check it until computed-value time. When it fails then, MDN explains that "the declaration is not ignored": the property falls back to its initial or inherited value. I tested the same pattern in Chromium with a made-up function: &lt;code&gt;background: red; background: notafunction(from var(--brand) / 16%)&lt;/code&gt; ends up transparent, not red. So use &lt;code&gt;@supports&lt;/code&gt;. &lt;code&gt;color-mix()&lt;/code&gt; has been Baseline since May 2023, which makes it a safe plan B. This is what the demo does:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.glass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;16%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;red&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.glass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;oklab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;16%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo checks the same thing in JavaScript with &lt;code&gt;CSS.supports('color', 'alpha(from red / 50%)')&lt;/code&gt; and shows a notice when it falls back.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokens resolve where they're declared.&lt;/strong&gt; If you write &lt;code&gt;--brand-soft: alpha(from var(--brand) / 20%)&lt;/code&gt; on &lt;code&gt;:root&lt;/code&gt; and then override &lt;code&gt;--brand&lt;/code&gt; inside &lt;code&gt;.teal&lt;/code&gt;, &lt;code&gt;var(--brand-soft)&lt;/code&gt; in that section still uses the root color. I checked this in Chromium: the token stayed orange, while the same &lt;code&gt;alpha()&lt;/code&gt; written directly on the element turned teal. Either declare the derived tokens on the element where &lt;code&gt;--brand&lt;/code&gt; changes, or use &lt;code&gt;alpha()&lt;/code&gt; inline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's &lt;code&gt;from&lt;/code&gt;, not a comma.&lt;/strong&gt; &lt;code&gt;alpha(red, 50%)&lt;/code&gt; is invalid. The &lt;code&gt;from&lt;/code&gt; keyword and the slash are both required.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Widely available" status.&lt;/strong&gt; Baseline "newly available" means every engine has shipped it, not that every user has updated. Keep the fallback for a while.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design tools catching up.&lt;/strong&gt; Token pipelines and design apps that export &lt;code&gt;rgba()&lt;/code&gt; copies of every shade could export a single color plus &lt;code&gt;alpha()&lt;/code&gt; calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS Color 5 as a whole.&lt;/strong&gt; &lt;code&gt;alpha()&lt;/code&gt; is new in Level 5, alongside relative color syntax and &lt;code&gt;color-mix()&lt;/code&gt;. It's the smallest function of the set, and probably the one you'll write most often.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/alpha" rel="noopener noreferrer"&gt;alpha() CSS function, MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-color-5/#relative-alpha" rel="noopener noreferrer"&gt;CSS Color Module Level 5: Relative Alpha Colors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors" rel="noopener noreferrer"&gt;Using relative colors, MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Syntax/Error_handling#invalid_custom_properties" rel="noopener noreferrer"&gt;CSS error handling: invalid custom properties, MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-11-02-css-alpha-one-brand-color-every-transparency" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>color</category>
      <category>designsystems</category>
      <category>baseline</category>
    </item>
    <item>
      <title>Intl.Segmenter: split text without breaking emoji</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:30:41 +0000</pubDate>
      <link>https://dev.to/richicodes/intlsegmenter-split-text-without-breaking-emoji-1id5</link>
      <guid>https://dev.to/richicodes/intlsegmenter-split-text-without-breaking-emoji-1id5</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-11-01-intl-segmenter-split-text-without-breaking-emoji" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have ever animated a headline letter by letter and watched an emoji turn into a row of broken boxes, the problem was never your CSS: it was &lt;code&gt;split("")&lt;/code&gt;. &lt;code&gt;Intl.Segmenter&lt;/code&gt; splits text into the pieces a reader actually perceives, and as of October 16, 2026 it is Baseline Widely available.&lt;/p&gt;

&lt;p&gt;Type anything into the demo above. The top row is what &lt;code&gt;text.split("")&lt;/code&gt; gives you; the bottom row is &lt;code&gt;Intl.Segmenter&lt;/code&gt;. With a family emoji, a flag and a word with an accent, the difference is hard to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;split("")&lt;/code&gt; lies to you
&lt;/h2&gt;

&lt;p&gt;JavaScript strings are sequences of UTF-16 code units. &lt;code&gt;length&lt;/code&gt;, &lt;code&gt;split("")&lt;/code&gt; and index access all work in those units, not in characters as a person sees them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;👨‍👩‍👧‍👦 café&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 16&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 16 → lone surrogate halves&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 12 → code points: the family falls apart&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spreading the string (&lt;code&gt;[...text]&lt;/code&gt;) is better, because it iterates by code points, but it is still not what you want. That family emoji is seven code points: four people glued together with three zero-width joiners. A flag is two regional indicator symbols. An accent can be a separate combining mark after the letter. None of that is a "character" to the reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Intl.Segmenter does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl.Segmenter&lt;/code&gt; performs locale-sensitive text segmentation. You create one with a locale and a &lt;code&gt;granularity&lt;/code&gt;, and its &lt;code&gt;segment()&lt;/code&gt; method returns an iterable &lt;code&gt;Segments&lt;/code&gt; object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ["👨‍👩‍👧‍👦", " ", "c", "a", "f", "é"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;granularity&lt;/code&gt; takes three values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"grapheme"&lt;/code&gt; (the default): user-perceived characters, the grapheme clusters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"word"&lt;/code&gt;: word boundaries, as determined by the locale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"sentence"&lt;/code&gt;: sentence boundaries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each segment is an object with &lt;code&gt;segment&lt;/code&gt; (the text), &lt;code&gt;index&lt;/code&gt; (where it starts in the original string, in code units) and &lt;code&gt;input&lt;/code&gt; (the whole string). With &lt;code&gt;granularity: "word"&lt;/code&gt; you also get &lt;code&gt;isWordLike&lt;/code&gt;, which is &lt;code&gt;true&lt;/code&gt; for letters, numbers or ideographs and &lt;code&gt;false&lt;/code&gt; for spaces and punctuation. That makes word counting a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;word&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isWordLike&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;countWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello, world! It’s 2026.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Word mode is where the API really earns its keep. Splitting on spaces does nothing for languages that don't put spaces between words, such as Japanese, Chinese or Thai. The MDN example segments &lt;code&gt;吾輩は猫である。名前はたぬき。&lt;/code&gt; into &lt;code&gt;吾輩 | は | 猫 | で | ある | 。 | 名前 | は | たぬき | 。&lt;/code&gt; and counts 8 words. Hit the "Japanese" preset in the demo: &lt;code&gt;split(" ")&lt;/code&gt; returns the whole sentence as one piece, and the segmenter returns the words.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split-text animation, done right
&lt;/h2&gt;

&lt;p&gt;This is the core of the demo above. Each grapheme goes into its own &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, with its position in a custom property so CSS can stagger the animation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.split&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pieces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;span&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--i&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.split&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pop&lt;/span&gt; &lt;span class="m"&gt;0.5s&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;45ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;pop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.5em&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The family emoji stays in one span, the flag stays a flag, and an accented letter keeps its accent. The demo wraps the same idea in a &lt;code&gt;prefers-reduced-motion&lt;/code&gt; check so the animation goes away for people who asked for less motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the segment at a position
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Segments&lt;/code&gt; has one more method that is easy to miss: &lt;code&gt;containing(index)&lt;/code&gt;. You give it a code-unit index and it returns the segment that covers it, or &lt;code&gt;undefined&lt;/code&gt; if the index is out of range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sentence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Find the word under the cursor.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;containing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// { segment: "word", index: 9, input: "...", isWordLike: true }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's exactly what you need to answer "which word is under the caret?" from an &lt;code&gt;input&lt;/code&gt;'s &lt;code&gt;selectionStart&lt;/code&gt;. In the demo, tapping or focusing a piece in the bottom row calls &lt;code&gt;containing()&lt;/code&gt; and shows the segment, its &lt;code&gt;index&lt;/code&gt;, its &lt;code&gt;.length&lt;/code&gt; in code units and the code points inside it. The family emoji reports a &lt;code&gt;.length&lt;/code&gt; of 11.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support and gotchas
&lt;/h2&gt;

&lt;p&gt;According to MDN's browser compatibility data, &lt;code&gt;Intl.Segmenter&lt;/code&gt; shipped in Chrome and Edge 87, Safari 14.1 (iOS 14.5) and Firefox 125, and in Node.js 16. web-features marks it Baseline Newly available on April 16, 2024, when Firefox 125 completed the set, and Widely available on October 16, 2026. Widely available is defined as 30 months after that keystone date.&lt;/p&gt;

&lt;p&gt;Things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;index&lt;/code&gt; is in code units.&lt;/strong&gt; That's convenient, because &lt;code&gt;text.slice(s.index, s.index + s.segment.length)&lt;/code&gt; gives you the segment back. Just don't mix it with code-point positions from &lt;code&gt;[...text]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isWordLike&lt;/code&gt; only exists in word mode.&lt;/strong&gt; With &lt;code&gt;"grapheme"&lt;/code&gt; or &lt;code&gt;"sentence"&lt;/code&gt; it is &lt;code&gt;undefined&lt;/code&gt;, not &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is locale-sensitive.&lt;/strong&gt; Pass the language of the text, not the language of your UI, when they differ. If you pass nothing, the runtime's default locale is used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;new&lt;/code&gt; is mandatory.&lt;/strong&gt; Calling &lt;code&gt;Intl.Segmenter()&lt;/code&gt; without it throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback.&lt;/strong&gt; If you still have to support older engines, check &lt;code&gt;"Segmenter" in Intl&lt;/code&gt;. The demo falls back to &lt;code&gt;Array.from()&lt;/code&gt;, which at least never splits surrogate pairs, and shows a warning. MDN links to a FormatJS polyfill if you need the real behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your own split-text code.&lt;/strong&gt; If a letter animation, a typewriter effect or a "reveal" component in your codebase uses &lt;code&gt;split("")&lt;/code&gt; or &lt;code&gt;[...text]&lt;/code&gt;, try it with an emoji or a combining accent. Now that the API is Widely available, swapping in a grapheme segmenter is a small change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Character counters.&lt;/strong&gt; Any "280 characters left" counter built on &lt;code&gt;.length&lt;/code&gt; is counting code units. Segmenting by grapheme gives you a number closer to what the user sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word mode outside English.&lt;/strong&gt; If your content is multilingual, word counts and "read time" estimates based on spaces are wrong for some languages. &lt;code&gt;isWordLike&lt;/code&gt; fixes that without a dictionary of your own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter" rel="noopener noreferrer"&gt;Intl.Segmenter - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter/Segmenter" rel="noopener noreferrer"&gt;Intl.Segmenter() constructor - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter/segment/Segments/containing" rel="noopener noreferrer"&gt;Segments.prototype.containing() - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/web-platform-dx/web-features/blob/main/docs/baseline.md" rel="noopener noreferrer"&gt;Baseline status definition - web-features&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-11-01-intl-segmenter-split-text-without-breaking-emoji" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>intl</category>
      <category>unicode</category>
      <category>typography</category>
    </item>
    <item>
      <title>CSS gap decorations: lines between grid items</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Sat, 31 Oct 2026 08:30:41 +0000</pubDate>
      <link>https://dev.to/richicodes/css-gap-decorations-lines-between-grid-items-3pp1</link>
      <guid>https://dev.to/richicodes/css-gap-decorations-lines-between-grid-items-3pp1</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-31-css-gap-decorations-lines-between-grid-items" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drawing a line between grid cells has always been a hack: borders on half the items, &lt;code&gt;:nth-child()&lt;/code&gt; rules to remove the extra ones, or a coloured background peeking through the gaps. Since Chrome and Edge 149 there's a real property for it: &lt;code&gt;row-rule&lt;/code&gt; and &lt;code&gt;column-rule&lt;/code&gt; now work on grid and flex containers, not just on multi-column text.&lt;/p&gt;

&lt;p&gt;I like this one because it fixes a very everyday problem. Bento layouts, dashboards, pricing tables, footers with columns: they all want separators, and separators belong to the gap, not to the items.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gap decorations are
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;column-rule&lt;/code&gt; isn't new. It has drawn the line between columns of multi-column text for years. The CSS Gaps module takes that idea and extends it to grid and flexbox, adds the row direction with &lt;code&gt;row-rule&lt;/code&gt;, and gives you control over where lines break, which gaps get a line, and how far the ends are pulled back.&lt;/p&gt;

&lt;p&gt;Two rules from the spec are worth knowing before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They take up no space.&lt;/strong&gt; A decoration never moves anything. If it's wider than the gap, the adjacent items simply overlap it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They're painted just above the container's border&lt;/strong&gt;, and in a scroll container they scroll with the items.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The code the demo uses
&lt;/h2&gt;

&lt;p&gt;The demo above is a four-column, four-row grid with nine tiles. The core is three declarations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bento&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;row-rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nx"&gt;#FF5200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;column-rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nx"&gt;#00D4AA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;row-rule&lt;/code&gt; and &lt;code&gt;column-rule&lt;/code&gt; are shorthands for &lt;code&gt;-width&lt;/code&gt;, &lt;code&gt;-style&lt;/code&gt; and &lt;code&gt;-color&lt;/code&gt;, exactly like &lt;code&gt;border&lt;/code&gt;. The defaults are also the same as a border's: &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;none&lt;/code&gt; and &lt;code&gt;currentcolor&lt;/code&gt;. So if you forget the style, nothing gets painted. If you want the same line in both directions, &lt;code&gt;rule&lt;/code&gt; sets both at once.&lt;/p&gt;

&lt;p&gt;The part that has no equivalent with borders is that the value is a &lt;strong&gt;list&lt;/strong&gt;. Choose the "Lists" pattern in the demo and you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bento&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;row-rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nx"&gt;#FF5200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt; &lt;span class="nx"&gt;#94A3B8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nx"&gt;#FF5200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;column-rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;dotted&lt;/span&gt; &lt;span class="nx"&gt;#00D4AA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt; &lt;span class="nx"&gt;#FF7E41&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each value goes to one gap, in order. When there are more gaps than values, the list starts again, so the columns alternate dotted and dashed. &lt;code&gt;repeat(auto, …)&lt;/code&gt; fills whatever the other values don't cover: here, first and last row gaps are thick and solid, and everything in between is a thin dashed line, however many rows there are. Only one &lt;code&gt;repeat(auto, …)&lt;/code&gt; is allowed per value. There's also &lt;code&gt;repeat(3, …)&lt;/code&gt; with a fixed count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaks, empty cells and insets
&lt;/h2&gt;

&lt;p&gt;The other three controls in the demo map to these properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rule-break&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;normal&lt;/code&gt; or &lt;code&gt;intersection&lt;/code&gt;. With &lt;code&gt;none&lt;/code&gt;, each line runs from one end of the gap to the other. With &lt;code&gt;intersection&lt;/code&gt;, it's cut at every T and every cross. &lt;code&gt;normal&lt;/code&gt; depends on the container: in grid it cuts at T junctions but runs through crosses; in flexbox it behaves like &lt;code&gt;none&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rule-visibility-items&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;all&lt;/code&gt;, &lt;code&gt;around&lt;/code&gt; or &lt;code&gt;between&lt;/code&gt;. It decides whether a line is painted next to an empty cell. Tap the big tile to remove it and switch to &lt;code&gt;between&lt;/code&gt;: the lines around the hole disappear, because there's no longer an item on both sides. The initial value, &lt;code&gt;normal&lt;/code&gt;, behaves like &lt;code&gt;all&lt;/code&gt; in grid. This one only applies to grid and multi-column containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rule-inset&lt;/code&gt;&lt;/strong&gt;: moves the ends of each segment. A positive value pulls them back from the junction, and percentages are resolved against the width of the gap being crossed. There's also an &lt;code&gt;overlap-join&lt;/code&gt; keyword, and longhands that separate the ends at the container's edges (&lt;code&gt;cap&lt;/code&gt;) from the ones at junctions (&lt;code&gt;junction&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When lines don't break, &lt;code&gt;rule-overlap&lt;/code&gt; decides which direction is painted on top. The initial value is &lt;code&gt;row-over-column&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Change patterns in the demo and the colour and width animate: the spec makes &lt;code&gt;-color&lt;/code&gt; and &lt;code&gt;-width&lt;/code&gt; animatable as lists, and I checked in Chromium that a &lt;code&gt;transition&lt;/code&gt; on &lt;code&gt;row-rule-color&lt;/code&gt; interpolates through intermediate colours. The style switches without interpolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome and Edge 149 only for now.&lt;/strong&gt; MDN's compatibility data marks &lt;code&gt;row-rule&lt;/code&gt;, &lt;code&gt;rule-break&lt;/code&gt;, &lt;code&gt;rule-inset&lt;/code&gt; and the rest as experimental, with no support in Firefox or Safari. The spec is an Editor's Draft that's still changing: the last Working Draft is from 24 June 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't feature-detect with &lt;code&gt;column-rule&lt;/code&gt;.&lt;/strong&gt; It has existed for multi-column since long before this, so the browser will say yes even if it doesn't paint anything in a grid. Ask about &lt;code&gt;row-rule&lt;/code&gt; instead (MDN's examples do the same with &lt;code&gt;@supports&lt;/code&gt;):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row-rule&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1px solid red&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fallback is free, but it's nothing.&lt;/strong&gt; In a browser that doesn't know these properties, the declarations are ignored and the gaps remain empty. For decorative lines, that's usually acceptable. If the separator carries meaning, you need a plan B. The demo uses the classic one when detection fails: paint the container with the line colour clipped to &lt;code&gt;content-box&lt;/code&gt;, with opaque, square-cornered items covering it. It works only for solid lines across every gap: no dashes, no breaks and no clean empty cells. I tested it in Chromium by forcing the fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lines don't make room.&lt;/strong&gt; If you increase the width without increasing the &lt;code&gt;gap&lt;/code&gt;, the items cover the line. Keep &lt;code&gt;gap&lt;/code&gt; at least as wide as the rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Firefox and Safari implementing it. Until then, use it as progressive enhancement and nothing more.&lt;/li&gt;
&lt;li&gt;Changes to the spec: property names and behaviours are still being refined, including how it works with the new grid lanes containers.&lt;/li&gt;
&lt;li&gt;Whether the design systems that today draw dividers with borders on each item start moving that to the container.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/row-rule" rel="noopener noreferrer"&gt;row-rule – MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Gaps" rel="noopener noreferrer"&gt;CSS gaps – MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-gaps-1/" rel="noopener noreferrer"&gt;CSS Gaps Module Level 1 – Editor's Draft&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-31-css-gap-decorations-lines-between-grid-items" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>grid</category>
      <category>ux</category>
      <category>layout</category>
    </item>
    <item>
      <title>Scroll anchoring is Baseline: content stops jumping</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:30:41 +0000</pubDate>
      <link>https://dev.to/richicodes/scroll-anchoring-is-baseline-content-stops-jumping-133o</link>
      <guid>https://dev.to/richicodes/scroll-anchoring-is-baseline-content-stops-jumping-133o</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-29-scroll-anchoring-is-baseline-content-stops-jumping" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You're halfway down a page, an ad or a batch of new posts loads above you, and the paragraph you were reading slides off the screen. Since Safari 27, every major browser fights that jump for you. Scroll anchoring, and the &lt;code&gt;overflow-anchor&lt;/code&gt; property that controls it, became Baseline on September 14, 2026.&lt;/p&gt;

&lt;p&gt;What surprised me is how old this is: MDN's compatibility data lists Chrome 56 and Firefox 66. What was missing was Safari, and with it the iPhone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What scroll anchoring does
&lt;/h2&gt;

&lt;p&gt;The classic behaviour of a scroll container is to keep its scroll offset fixed. If something 300 px tall appears above what you're looking at, the offset stays the same, so everything you can see moves down by 300 px. The spec's introduction names the usual culprits: script inserting an ad iframe, or images without dimensions loading on a slow network.&lt;/p&gt;

&lt;p&gt;Scroll anchoring flips that. The browser picks an &lt;strong&gt;anchor node&lt;/strong&gt; inside the scroller, remembers where it is, and when a layout change moves it, adjusts the scroll offset by exactly the same amount. Your scroll position changes, but what you're looking at stays put.&lt;/p&gt;

&lt;p&gt;That's what the demo above shows. Both panes are the same feed, the highlighted card is "where you are reading", and the dashed line marks where it was. Press "+ 3 posts above" or load the ad: on the left the card drops and the meter counts the pixels it moved; on the right the meter stays at 0 px and &lt;code&gt;scrollTop&lt;/code&gt; grows instead. "Auto feed" keeps inserting posts every 1.4 seconds, which is the timeline-with-new-items situation where this matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code: it's on by default
&lt;/h2&gt;

&lt;p&gt;There's nothing to turn on. The only CSS in the demo is the opt-out on the left pane:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* scroll anchoring applies here by default */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.bad&lt;/span&gt; &lt;span class="nc"&gt;.feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;overflow-anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* the old behaviour: the content jumps */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;overflow-anchor&lt;/code&gt; takes two values. &lt;code&gt;auto&lt;/code&gt;, the initial value, makes an element eligible to be an anchor. &lt;code&gt;none&lt;/code&gt; takes the element &lt;em&gt;and its descendants&lt;/em&gt; out of anchor selection, and set on the scroller itself it disables anchoring for that scroller. The property doesn't inherit, but the spec notes you can't turn anchoring back on inside a &lt;code&gt;none&lt;/code&gt; subtree, with one exception: a nested scroll container gets its own anchoring again unless it also says &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How the anchor is chosen is worth knowing too. The spec's algorithm walks the scroller's children looking for a node that's fully visible, going deeper when a node is only partially visible, because deeper nodes are less likely to change internally. Elements with &lt;code&gt;display: none&lt;/code&gt;, &lt;code&gt;position: fixed&lt;/code&gt;, &lt;code&gt;position: sticky&lt;/code&gt; or an &lt;code&gt;absolute&lt;/code&gt; box positioned relative to something outside the scroller are skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback, if you need one
&lt;/h2&gt;

&lt;p&gt;In browsers without native anchoring, the demo emulates it with the same idea in a few lines: note the reading card's position, insert the content, and compensate the difference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overflow-anchor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;insertAbove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                   &lt;span class="c1"&gt;// whatever adds content above&lt;/span&gt;
  &lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetTop&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// put the reader back&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;offsetTop&lt;/code&gt; is relative to the feed because it has &lt;code&gt;position: relative&lt;/code&gt;. I checked this snippet in Chromium with native anchoring switched off, and the card moved 0 px. Don't run it where anchoring is native: you'd compensate twice. In the demo, when the fallback is active, the right pane also gets &lt;code&gt;overflow-anchor: none&lt;/code&gt; so only the script adjusts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit or checked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing happens at the very top.&lt;/strong&gt; The spec says that if the scroller isn't scrolled away from its origin, no anchor is selected. I tested it: with &lt;code&gt;scrollTop&lt;/code&gt; at 0, inserting a 50 px element above moved the content 52 px (the element plus its borders). That's deliberate: if you're at the top of a feed, you probably want to see the new items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only protects the anchor.&lt;/strong&gt; While building the demo on a 375 px screen, the ad was first inserted &lt;em&gt;between&lt;/em&gt; the visible top of the pane and the reading card. Following the spec's algorithm, the anchor is the first fully visible card from the top, which sat above the insertion point. That card stayed still, and my reading card got pushed down 150 px anyway. Correct per the spec, and a good reminder: anchoring compensates for changes above the anchor node, not for everything on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suppression triggers.&lt;/strong&gt; If, within the same suppression window (roughly, the same turn of the event loop), something changes &lt;code&gt;top&lt;/code&gt;/&lt;code&gt;left&lt;/code&gt;/&lt;code&gt;right&lt;/code&gt;/&lt;code&gt;bottom&lt;/code&gt;, &lt;code&gt;margin&lt;/code&gt;, &lt;code&gt;padding&lt;/code&gt;, a width or height property, &lt;code&gt;position&lt;/code&gt; or &lt;code&gt;transform&lt;/code&gt; on the path between the anchor node and the scroller, the adjustment is cancelled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fires scroll events.&lt;/strong&gt; The adjustment counts as a scroll, so &lt;code&gt;scroll&lt;/code&gt; listeners will see it. MDN's guide says that if a page misbehaves with anchoring, the usual reason is a scroll listener that doesn't expect this extra scrolling, and that Firefox can highlight the chosen anchor with the &lt;code&gt;layout.css.scroll-anchoring.highlight&lt;/code&gt; flag in &lt;code&gt;about:config&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't replace reserving space.&lt;/strong&gt; Setting &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on images or a &lt;code&gt;min-height&lt;/code&gt; on ad slots still prevents the shift in the first place, including at the top of the page where anchoring stays out of the way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari 27 in the wild.&lt;/strong&gt; Baseline "newly available" means the latest versions support it, not that everyone has updated. Until older iPhones age out, Safari users on 26 and earlier still get the jump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feeds and chat UIs.&lt;/strong&gt; Prepending older messages or new posts was a classic place for hand-written &lt;code&gt;scrollTop&lt;/code&gt; fixes. Now that every engine anchors, those fixes can compensate twice, so it's a good moment to check if they're still needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spec is still a draft.&lt;/strong&gt; The suppression window, the slice of the event loop in which adjustments are batched, is only defined in this draft; the spec itself notes it should move into the HTML standard eventually.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow-anchor" rel="noopener noreferrer"&gt;overflow-anchor | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_anchoring/Overview" rel="noopener noreferrer"&gt;Overview of scroll anchoring | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-scroll-anchoring/" rel="noopener noreferrer"&gt;CSS Scroll Anchoring Module Level 1 (Editor's Draft)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-29-scroll-anchoring-is-baseline-content-stops-jumping" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>scrolling</category>
      <category>ux</category>
      <category>baseline</category>
    </item>
    <item>
      <title>CSS style queries: one variable, every style</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:30:43 +0000</pubDate>
      <link>https://dev.to/richicodes/css-style-queries-one-variable-every-style-3geo</link>
      <guid>https://dev.to/richicodes/css-style-queries-one-variable-every-style-3geo</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-28-css-style-queries-one-variable-every-style" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can now write &lt;code&gt;@container style(--status: warn)&lt;/code&gt; and have CSS react to the value of a custom property, in every major browser. Container style queries became Baseline on May 19, 2026, when Firefox 151 shipped them, joining Chrome and Edge 111 and Safari 18.&lt;/p&gt;

&lt;p&gt;Size container queries got all the attention. Style queries are the quieter half of the same feature, and I think they're the more surprising one: instead of asking "how wide is my container?", a rule asks "what value does my container have for this variable?". That turns a custom property into a switch that CSS can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problem it solves
&lt;/h2&gt;

&lt;p&gt;Think of a status card with three states: operational, degraded, down. Each state changes the border, the glow, the icon, the badge text and the colour of a chart. The usual answer is a modifier class or a &lt;code&gt;data-status&lt;/code&gt; attribute, plus one selector per state for every piece of the card.&lt;/p&gt;

&lt;p&gt;With a style query, the state lives in one custom property, and every descendant can ask about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"--status: warn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"badge"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Degraded&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;212&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;170&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;251&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;191&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;36&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick in the demo above. Each card carries &lt;code&gt;style="--status: ok"&lt;/code&gt; and the only thing the JavaScript does on click is &lt;code&gt;card.style.setProperty('--status', next)&lt;/code&gt;. The colour, the &lt;code&gt;▲&lt;/code&gt;/&lt;code&gt;✕&lt;/code&gt; icon, the badge text (via &lt;code&gt;content: var(--badge)&lt;/code&gt;), the latency figure, the progress bar and the pulsing ring are all CSS reacting to that value. The board view uses a second variable, &lt;code&gt;--view: list&lt;/code&gt;, on the board element, and even the highlighted line in the code panel is a style query reading &lt;code&gt;--last&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What makes this different from an attribute selector is that custom properties inherit and cascade. The value can come from a parent, a theme class higher up, a media query or an inline style, and every element below it can query it without knowing where it was set.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A few rules that I checked against MDN and the spec, and then in the browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;container-type&lt;/code&gt; needed.&lt;/strong&gt; Every element is a style container. You only need &lt;code&gt;container-type&lt;/code&gt; for size queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An element queries its ancestors, not itself.&lt;/strong&gt; The query is evaluated against an ancestor container, so in the example above &lt;code&gt;.badge&lt;/code&gt; can react to &lt;code&gt;--status&lt;/code&gt;, but a rule that tries to style &lt;code&gt;.card&lt;/code&gt; itself from its own &lt;code&gt;--status&lt;/code&gt; won't match. That's why every card in the demo has an inner &lt;code&gt;.body&lt;/code&gt; that carries the visual styles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stick to custom properties.&lt;/strong&gt; The spec also allows normal properties, as in &lt;code&gt;style(color: red)&lt;/code&gt;, but MDN's guide says custom properties are the only style feature currently supported, and that query didn't match in the Chromium I tested with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A query without a value checks "is it set?".&lt;/strong&gt; &lt;code&gt;style(--flag)&lt;/code&gt; is true when the computed value differs from the initial value. For an unregistered custom property, that means any value at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gotcha: plain matching compares tokens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;style(--status: warn)&lt;/code&gt; compares the computed value of the property with the value you wrote. For an unregistered custom property, that computed value is basically the text you wrote, so &lt;code&gt;--accent: #00f&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; match &lt;code&gt;style(--accent: blue)&lt;/code&gt;, and &lt;code&gt;--n: calc(6/2)&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; match &lt;code&gt;style(--n: 3)&lt;/code&gt;. I tested both in Chromium and neither matches.&lt;/p&gt;

&lt;p&gt;If you need equivalent values to match, register the property with a type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="l"&gt;--accent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;color&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Now --accent: #00f matches too */&lt;/span&gt;
&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.icon&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For keyword-like states such as &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;warn&lt;/code&gt; and &lt;code&gt;down&lt;/code&gt;, plain matching is exactly what you want, and you don't need &lt;code&gt;@property&lt;/code&gt; at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support and other gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Support:&lt;/strong&gt; Chrome and Edge 111, Safari 18, Firefox 151. Baseline "newly available" since May 19, 2026, so check your audience before you drop a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari and &lt;code&gt;:root&lt;/code&gt;:&lt;/strong&gt; MDN's compatibility data notes that in Safari the document element can't be a container. If you set a variable on &lt;code&gt;:root&lt;/code&gt; and query it, the children of &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; are fine, because &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; inherits the value and acts as the container. A rule that styles &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; itself from it won't match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature detection:&lt;/strong&gt; &lt;code&gt;@supports&lt;/code&gt; can't test an at-rule like this. In the demo I use a tiny probe: a hidden element sets &lt;code&gt;--sq: yes&lt;/code&gt;, a style query gives its child a 7 px width, and JavaScript checks the computed width. If it doesn't match, the page adds a class and falls back to &lt;code&gt;data-status&lt;/code&gt; attribute selectors that set the same variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Range syntax isn't Baseline.&lt;/strong&gt; MDN documents a newer form with comparison operators, such as &lt;code&gt;style(--columns &amp;gt;= 3)&lt;/code&gt;, which compares values numerically. MDN's compatibility data lists Chrome 142 and no Safari support yet, so I left it out of the demo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Range syntax across engines.&lt;/strong&gt; Numeric comparisons like &lt;code&gt;style(0 &amp;lt; --n &amp;lt; 10)&lt;/code&gt; are where style queries get really expressive. Once Safari ships them, a lot of "set a class from JavaScript when the number crosses a threshold" code can go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Querying normal properties.&lt;/strong&gt; &lt;code&gt;style(display: flex)&lt;/code&gt; or &lt;code&gt;style(font-weight: bold)&lt;/code&gt; are in the spec, but you can't count on them in every browser yet. When they arrive, components will be able to adapt to how their parent is styled, not only to variables you set on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design systems.&lt;/strong&gt; A single &lt;code&gt;--variant&lt;/code&gt; or &lt;code&gt;--tone&lt;/code&gt; variable on a component, with style queries inside it, is a clean alternative to stacks of modifier classes. I expect to see that pattern a lot more now that it works everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_size_and_style_queries" rel="noopener noreferrer"&gt;Using container size and style queries | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container" rel="noopener noreferrer"&gt;@container | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/151" rel="noopener noreferrer"&gt;Firefox 151 release notes for developers | MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-conditional-5/#style-container" rel="noopener noreferrer"&gt;CSS Conditional Rules Module Level 5: Style Container Features&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-28-css-style-queries-one-variable-every-style" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>containerqueries</category>
      <category>customproperties</category>
      <category>baseline</category>
    </item>
    <item>
      <title>CSS light-dark() now swaps images, not just colors</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:30:39 +0000</pubDate>
      <link>https://dev.to/richicodes/css-light-dark-now-swaps-images-not-just-colors-53g2</link>
      <guid>https://dev.to/richicodes/css-light-dark-now-swaps-images-not-just-colors-53g2</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-27-css-light-dark-now-swaps-images-not-just-colors" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;light-dark()&lt;/code&gt; has been swapping colors for a while. Now it swaps images too: give it two gradients, two &lt;code&gt;url()&lt;/code&gt;s, or an image and &lt;code&gt;none&lt;/code&gt;, and the browser picks one based on the element's color scheme. As of September 14, 2026 that works in Chrome, Edge, Firefox and Safari, which makes it Baseline newly available.&lt;/p&gt;

&lt;p&gt;The landscape in the demo above has no image files and no theming JavaScript. The sky, the sun that turns into a moon, the stars, the clouds, the hills and the lit windows are all &lt;code&gt;background-image: light-dark(day, night)&lt;/code&gt;. The JavaScript in the demo only does two things: it changes the scene's &lt;code&gt;color-scheme&lt;/code&gt; when you click Day or Night, and it writes the code panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Dark mode always had a gap. Colors were easy with &lt;code&gt;light-dark()&lt;/code&gt; or custom properties, but any time an image had to change (a logo, a hero gradient, a list bullet, a decorative background) you went back to &lt;code&gt;@media (prefers-color-scheme: dark)&lt;/code&gt; and duplicated the rule.&lt;/p&gt;

&lt;p&gt;A media query only knows what the user picked in their system or browser. It doesn't know that one section of your page is forced to &lt;code&gt;color-scheme: dark&lt;/code&gt;. &lt;code&gt;light-dark()&lt;/code&gt; resolves against the color scheme of the element it's applied to, so the same rule does the right thing in a dark sidebar sitting inside a light page. The three logo tiles at the bottom of the demo share one CSS rule: one tile is &lt;code&gt;color-scheme: light&lt;/code&gt;, one is &lt;code&gt;dark&lt;/code&gt;, and one is &lt;code&gt;light dark&lt;/code&gt;, which follows your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The spec now defines two forms. The color form you already know, and an image form where each argument is an &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt; or &lt;code&gt;none&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;#38bdf8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;#fde68a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c"&gt;/* light */&lt;/span&gt;
    &lt;span class="nf"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;#050816&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;#4c1d95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;/* dark */&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first argument is used when the element's color scheme is light. The second is used when it's dark. As MDN notes, you have to opt in with &lt;code&gt;color-scheme: light dark&lt;/code&gt; (usually on &lt;code&gt;:root&lt;/code&gt;). Without it, the page stays light and you always get the first image.&lt;/p&gt;

&lt;p&gt;To force one area to a scheme, set &lt;code&gt;color-scheme&lt;/code&gt; on that element. That's exactly what the demo's switch does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.scene&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"light"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.scene&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;none&lt;/code&gt; is valid on either side. The spec defines it as a fully transparent image with no natural size, and in Chrome the computed value reads &lt;code&gt;image(rgba(0, 0, 0, 0))&lt;/code&gt;. It's handy for layers that should only appear in one scheme, like the stars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.stars&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt; &lt;span class="m"&gt;30px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;#fff&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;51%&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works anywhere an image goes, not only on backgrounds. The spec's own example uses it for list bullets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="nc"&gt;.fancy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;list-style-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"light.svg"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"dark.svg"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked all of these snippets in Chromium by reading &lt;code&gt;getComputedStyle()&lt;/code&gt; under a light and a dark scheme. Each time the browser returned the image I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that cost me a layer
&lt;/h2&gt;

&lt;p&gt;My first version of the demo had no stars. I had written this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Does NOT work: three images on the dark side */&lt;/span&gt;
&lt;span class="nc"&gt;.stars&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each side of &lt;code&gt;light-dark()&lt;/code&gt; takes a single image. A comma-separated list makes the whole value invalid, and the property falls back to &lt;code&gt;none&lt;/code&gt;. The fix is one &lt;code&gt;light-dark()&lt;/code&gt; per layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.stars&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--star1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--star2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--star3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;var()&lt;/code&gt; it's harder to spot. The browser can't reject the declaration while parsing because it doesn't know what the variables contain yet, but in my test the result was still &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support
&lt;/h2&gt;

&lt;p&gt;According to MDN's browser compatibility data, the image form shipped in Chrome and Edge 150, Firefox 150 and Safari 27. The color form has been around much longer: Chrome 123, Firefox 120 and Safari 17.5. If you test only with colors in an older browser, you may wrongly assume images work as well.&lt;/p&gt;

&lt;p&gt;The demo checks support with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;background-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light-dark(none, linear-gradient(red, blue))&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns false, the demo shows a notice and switches to a plan B: the same gradients chosen with a &lt;code&gt;data-resolved&lt;/code&gt; attribute. In plain CSS, the fallback can be an &lt;code&gt;@supports&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fade you see when you switch between day and night isn't part of &lt;code&gt;light-dark()&lt;/code&gt;. It comes from a View Transition (&lt;code&gt;document.startViewTransition&lt;/code&gt;), and the demo skips it when &lt;code&gt;prefers-reduced-motion&lt;/code&gt; is on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Older browsers in your traffic.&lt;/strong&gt; This is newly available, not widely available. Keep a fallback for anything important, like a logo that disappears on a dark background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image loading.&lt;/strong&gt; Before you swap two heavy raster images this way, check in your own DevTools Network panel what each browser downloads and when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where you use it.&lt;/strong&gt; Logos, illustrations and decorative gradients are the obvious first targets. Anything that used to need a duplicated rule inside a &lt;code&gt;prefers-color-scheme&lt;/code&gt; query is worth reviewing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark" rel="noopener noreferrer"&gt;light-dark() CSS function — MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-color-5/#typedef-light-dark-image" rel="noopener noreferrer"&gt;CSS Color Module Level 5: light-dark() — W3C Editor's Draft&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme" rel="noopener noreferrer"&gt;color-scheme CSS property — MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-27-css-light-dark-now-swaps-images-not-just-colors" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>darkmode</category>
      <category>baseline</category>
      <category>images</category>
    </item>
    <item>
      <title>View Transitions: animate any DOM change with one call</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:30:37 +0000</pubDate>
      <link>https://dev.to/richicodes/view-transitions-animate-any-dom-change-with-one-call-4nod</link>
      <guid>https://dev.to/richicodes/view-transitions-animate-any-dom-change-with-one-call-4nod</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-26-view-transitions-animate-any-dom-change" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You change the DOM however you like, wrap that change in &lt;code&gt;document.startViewTransition()&lt;/code&gt;, and the browser animates from the old state to the new one. No FLIP math, no animation library, no keeping two copies of the UI alive. Same-document view transitions have been Baseline since October 14, 2025, and the parts that make them scale (&lt;code&gt;view-transition-class&lt;/code&gt; and transition types) joined them by January 2026.&lt;/p&gt;

&lt;p&gt;The demo above is a bento grid where nothing is animated by hand. The grid simply gets reordered, filtered or switched to a list, and a card turns into a full panel. Turn on &lt;strong&gt;Slow motion&lt;/strong&gt; to watch it, or untick &lt;strong&gt;View Transitions&lt;/strong&gt; to see the same DOM changes without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is and why it matters
&lt;/h2&gt;

&lt;p&gt;Animating a layout change used to mean measuring every element before and after, then faking the motion with transforms. The View Transition API flips that around. When you call &lt;code&gt;startViewTransition(update)&lt;/code&gt;, the browser takes a snapshot of the current page, runs your &lt;code&gt;update&lt;/code&gt; callback, captures the new state, and animates between the two with a tree of pseudo-elements that sits on top of the page.&lt;/p&gt;

&lt;p&gt;By default the whole page is one snapshot called &lt;code&gt;root&lt;/code&gt;, and you get a cross-fade. The spec's user agent stylesheet sets it to a quarter of a second. Give an element its own &lt;code&gt;view-transition-name&lt;/code&gt; and it gets its own group, which the browser animates from its old position and size to its new ones. That's where the "wow" comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the demo works
&lt;/h2&gt;

&lt;p&gt;Everything goes through one function. This is a trimmed version of the one in the demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startViewTransition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Plan B: same change, no animation&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startViewTransition&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// If another transition replaces this one before it starts animating,&lt;/span&gt;
  &lt;span class="c1"&gt;// ready rejects with an AbortError. Handle it or the console will complain.&lt;/span&gt;
  &lt;span class="nx"&gt;vt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;vt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Shuffling is just reordering DOM nodes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shuffled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shuffle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nf"&gt;shuffled&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;])));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each card gets a unique name and a shared class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewTransitionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`tile-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.tile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;view-transition-class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Don't capture the rest of the page: the toolbar updates instantly */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;view-transition-name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* One rule for all twelve cards, thanks to the class */&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-group&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.55s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation-timing-function&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;view-transition-class&lt;/code&gt; you'd write a rule for &lt;code&gt;tile-0&lt;/code&gt;, another for &lt;code&gt;tile-1&lt;/code&gt;, and so on. A class doesn't need to be unique and doesn't create a group; it's only a styling hook for elements that already have a name.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;types&lt;/code&gt; array is what lets one API do several choreographies. In CSS, &lt;code&gt;:active-view-transition-type()&lt;/code&gt; matches the root element while a transition of that type is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root:active-view-transition-type&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;&amp;amp;::view-transition-new(.tile):only-child&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pop-in&lt;/span&gt; &lt;span class="m"&gt;.45s&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nd"&gt;:only-child&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pop-out&lt;/span&gt; &lt;span class="m"&gt;.3s&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;/* pop-in and pop-out are ordinary @keyframes (scale + opacity) */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:only-child&lt;/code&gt; trick comes from the spec. When a card is filtered out it only exists in the old state, so its group has an old image and no new one. Cards that come back only have a new image. That's how the demo pops cards in and out while the rest slide into place.&lt;/p&gt;

&lt;p&gt;The card-to-panel morph doesn't need anything new. The name moves from one element to another inside the update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewTransitionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewTransitionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// same name, different element&lt;/span&gt;
  &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same name in the old and new state means same group, so the browser animates the card's box into the panel's box and cross-fades the content. I also give that pair a &lt;code&gt;hero&lt;/code&gt; class with &lt;code&gt;::view-transition-group(.hero) { z-index: 1; }&lt;/code&gt;. Without it, in my first version the growing panel was painted underneath the cards that come after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit or checked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Names must be unique among rendered elements.&lt;/strong&gt; If two rendered elements share a &lt;code&gt;view-transition-name&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt; rejects and the transition is skipped. In Chromium I got an &lt;code&gt;InvalidStateError&lt;/code&gt;. Hidden elements (&lt;code&gt;display: none&lt;/code&gt;) don't count, which is why filtering works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interrupting a transition.&lt;/strong&gt; Calling &lt;code&gt;startViewTransition()&lt;/code&gt; while another is active skips the old one with an &lt;code&gt;AbortError&lt;/code&gt;. If that happens before it starts animating, its &lt;code&gt;ready&lt;/code&gt; promise rejects, and with no handler Chromium logs an uncaught "Transition was skipped" error. If it happens mid-animation, &lt;code&gt;ready&lt;/code&gt; has already resolved and &lt;code&gt;finished&lt;/code&gt; simply resolves early. Press &lt;strong&gt;Shuffle&lt;/strong&gt; twice quickly in the demo and the log shows both cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clicks during the animation.&lt;/strong&gt; The &lt;code&gt;::view-transition&lt;/code&gt; overlay covers the page. In Chromium, a click during the animation hit &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, not the button underneath, until I added &lt;code&gt;::view-transition { pointer-events: none; }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The old state is a picture.&lt;/strong&gt; The old snapshot is a static image and the new one is live, so stretched text during a size change is normal. MDN's trick of setting &lt;code&gt;height: 100%&lt;/code&gt; on &lt;code&gt;::view-transition-old()&lt;/code&gt; and &lt;code&gt;::view-transition-new()&lt;/code&gt; smooths over the different aspect ratios, and the demo uses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced motion is on you.&lt;/strong&gt; With reduced motion emulated in Chromium, the transitions still ran. The demo sets &lt;code&gt;animation: none&lt;/code&gt; on the view transition pseudo-elements inside &lt;code&gt;@media (prefers-reduced-motion: reduce)&lt;/code&gt;, so changes apply instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Browser support
&lt;/h2&gt;

&lt;p&gt;According to MDN's compat data, &lt;code&gt;document.startViewTransition()&lt;/code&gt; works in Chrome 111, Firefox 144 and Safari 18. The options object with &lt;code&gt;types&lt;/code&gt;, and &lt;code&gt;:active-view-transition-type()&lt;/code&gt;, arrived in Chrome 125, Firefox 147 and Safari 18.2. &lt;code&gt;view-transition-class&lt;/code&gt; is in Chrome 125, Firefox 144 and Safari 18.2. I ran the demo in Chromium 153 and WebKitGTK 2.52 with no console errors; I couldn't run Firefox in my environment. When the method doesn't exist, the demo shows a notice and applies the same changes instantly, which is exactly how the API was designed to degrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Element-scoped transitions.&lt;/strong&gt; MDN already documents &lt;code&gt;Element.startViewTransition()&lt;/code&gt; for running transitions inside a subtree, but &lt;code&gt;ViewTransition.transitionRoot&lt;/code&gt; is still marked experimental and Chromium-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;match-element&lt;/code&gt;.&lt;/strong&gt; It gives every selected element an automatic unique name, so you can skip the &lt;code&gt;tile-${i}&lt;/code&gt; step for same-document transitions. Its support is in Chrome 137, Firefox 144 and Safari 18.4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-document transitions.&lt;/strong&gt; The &lt;code&gt;@view-transition&lt;/code&gt; rule for multi-page sites isn't Baseline yet; web-features still lists it without Firefox support.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/mdn/content/main/files/en-us/web/api/document/startviewtransition/index.md" rel="noopener noreferrer"&gt;Document: startViewTransition() method (MDN source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/mdn/content/main/files/en-us/web/api/view_transition_api/using_types/index.md" rel="noopener noreferrer"&gt;Using view transition types (MDN source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/mdn/content/main/files/en-us/web/css/reference/properties/view-transition-class/index.md" rel="noopener noreferrer"&gt;view-transition-class (MDN source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/w3c/csswg-drafts/main/css-view-transitions-1/Overview.bs" rel="noopener noreferrer"&gt;CSS View Transitions Module Level 1 (spec source)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/web-platform-dx/web-features/main/features/view-transitions.yml.dist" rel="noopener noreferrer"&gt;web-features: view transitions Baseline status&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-26-view-transitions-animate-any-dom-change" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>javascript</category>
      <category>animation</category>
      <category>viewtransitions</category>
    </item>
    <item>
      <title>CSS field-sizing: inputs that grow as you type</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:30:39 +0000</pubDate>
      <link>https://dev.to/richicodes/css-field-sizing-inputs-that-grow-as-you-type-283i</link>
      <guid>https://dev.to/richicodes/css-field-sizing-inputs-that-grow-as-you-type-283i</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-25-css-field-sizing-inputs-that-grow-as-you-type" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A textarea that grows as you type used to need a script that measured &lt;code&gt;scrollHeight&lt;/code&gt; on every keystroke. Now it takes one line of CSS: &lt;code&gt;field-sizing: content&lt;/code&gt;. With Firefox 152 shipping it on June 16, 2026, the property is Baseline, so it works in Chrome, Edge, Firefox and Safari.&lt;/p&gt;

&lt;p&gt;Type in the demo above, or press "Type it for me": the blanks in the sentence widen with every letter, the dropdown shrinks to the option you picked, and the chat box gains a line at a time until it hits its limit. Flip the switch to &lt;code&gt;fixed&lt;/code&gt; and you get the old behaviour back on the same markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is and why it matters
&lt;/h2&gt;

&lt;p&gt;Form controls have always had a size that ignores what's inside them. An &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; is just as wide whether it holds "Al" or a full street address, a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; is however many &lt;code&gt;rows&lt;/code&gt; and &lt;code&gt;cols&lt;/code&gt; it gets, and a &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; is as wide as its longest option. The spec calls these "elements with default preferred size".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;field-sizing&lt;/code&gt; takes two values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fixed&lt;/code&gt;: the default. The control keeps that default preferred size.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;content&lt;/code&gt;: the browser works out the control's size from its content and ignores the default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole API. What makes it worth knowing is how many small annoyances it removes: chat composers, comment boxes, inline "fill in the blank" forms, tag inputs, compact filters with a &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; that shouldn't reserve space for its longest option. All of that used to mean JavaScript that measured text, ran on every &lt;code&gt;input&lt;/code&gt; event and fought with fonts that hadn't loaded yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;This is the CSS behind the demo, trimmed to the parts that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* The technique: fields take the size of their content */&lt;/span&gt;
&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;textarea&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;field-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Inline blanks: never narrower than 3 characters,
   never wider than their container */&lt;/span&gt;
&lt;span class="nc"&gt;.sentence&lt;/span&gt; &lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.sentence&lt;/span&gt; &lt;span class="nt"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Chat: fixed width, the height grows up to a limit and then scrolls */&lt;/span&gt;
&lt;span class="nc"&gt;.composer&lt;/span&gt; &lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.75em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;max-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7.5em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three behaviours are going on there:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Text inputs shrink-wrap their text.&lt;/strong&gt; The &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;tel&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; types grow and shrink with the value. If there's a &lt;code&gt;placeholder&lt;/code&gt;, the field is wide enough to show it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A textarea grows sideways first, then down.&lt;/strong&gt; With nothing stopping it, a textarea with &lt;code&gt;field-sizing: content&lt;/code&gt; behaves like a single-line field. Once it can't get any wider, because it has a fixed &lt;code&gt;width&lt;/code&gt; or hits a &lt;code&gt;max-width&lt;/code&gt;, it starts adding lines. When it reaches &lt;code&gt;max-height&lt;/code&gt;, a scrollbar shows up. That's why the chat in the demo has &lt;code&gt;width: 100%&lt;/code&gt;: the width stays put and only the height changes. The spec uses exactly this case as its example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dropdown fits the selected option.&lt;/strong&gt; A regular &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; changes width as you pick different options. A list box (&lt;code&gt;multiple&lt;/code&gt;, or &lt;code&gt;size&lt;/code&gt; above 1) shows every option instead of scrolling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the demo the only JavaScript that touches sizes is the &lt;code&gt;ResizeObserver&lt;/code&gt; that prints the pixel readouts, plus the fallback I'll get to below. The growing itself is all CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;I checked each of these in Chromium with a small test page before writing them down. They match what MDN describes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always set a &lt;code&gt;min-width&lt;/code&gt;.&lt;/strong&gt; An empty input with &lt;code&gt;field-sizing: content&lt;/code&gt; and no minimum is as wide as the text cursor. In my test it measured 9 pixels. That's why the demo uses &lt;code&gt;min-width: 3ch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;size&lt;/code&gt;, &lt;code&gt;rows&lt;/code&gt; and &lt;code&gt;cols&lt;/code&gt; stop doing anything.&lt;/strong&gt; They set the default preferred size, and &lt;code&gt;content&lt;/code&gt; ignores that. An &lt;code&gt;&amp;lt;input size="40"&amp;gt;&lt;/code&gt; with &lt;code&gt;field-sizing: content&lt;/code&gt; was exactly as narrow as one without the attribute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; cancel the effect.&lt;/strong&gt; If you set both, the control has a fixed size again. Use &lt;code&gt;min-*&lt;/code&gt; and &lt;code&gt;max-*&lt;/code&gt; to keep it within bounds, or fix only the width, as in the chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxlength&lt;/code&gt; also caps the size&lt;/strong&gt;, because the field stops growing when no more characters fit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the container.&lt;/strong&gt; In a CSS grid, an item's automatic minimum size can let a growing field push the whole card wider than the screen. That's exactly what my first version of this demo did at 375 px. &lt;code&gt;grid-template-columns: minmax(0, 1fr)&lt;/code&gt; on the parent fixes it, and so does &lt;code&gt;min-width: 0&lt;/code&gt; on the item.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Browser support
&lt;/h2&gt;

&lt;p&gt;According to MDN's compatibility data, Chrome and Edge 123 (March 2024) were first, followed by Safari 26.2 (December 2025) and Firefox 152 (June 2026). That makes it Baseline newly available from June 16, 2026. For the next few months, some people will still be on browsers without it.&lt;/p&gt;

&lt;p&gt;The good news is that the fallback is free: a browser that doesn't understand &lt;code&gt;field-sizing&lt;/code&gt; ignores the declaration and draws the form exactly as it always has. If the old size is acceptable, you don't need anything else. If it isn't, check for support and only load the old trick when the property is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;field-sizing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Old trick: measure and set the size by hand&lt;/span&gt;
  &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo does exactly that, and shows a notice when it's running on the fallback. In CSS you can use &lt;code&gt;@supports (field-sizing: content)&lt;/code&gt; for the same check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The spec home has changed.&lt;/strong&gt; &lt;code&gt;field-sizing&lt;/code&gt; now lives in CSS Form Control Styling Level 1 (it moved there from CSS UI 4). That draft also covers &lt;code&gt;appearance: base&lt;/code&gt; and fully styleable form controls, so expect more news in this area.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long fields in real layouts.&lt;/strong&gt; A field that grows changes the layout around it. In flex and grid that's fine, but a &lt;code&gt;max-width&lt;/code&gt; is always worth having.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing old code.&lt;/strong&gt; If you have a dependency or a hook whose only job is to auto-size textareas, it's a good candidate to delete once your audience is on browsers from the last few months.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/field-sizing" rel="noopener noreferrer"&gt;field-sizing - MDN Web Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-forms-1/#field-sizing" rel="noopener noreferrer"&gt;CSS Form Control Styling Module Level 1: the field-sizing property&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/152" rel="noopener noreferrer"&gt;Firefox 152 release notes for developers - MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-25-css-field-sizing-inputs-that-grow-as-you-type" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>forms</category>
      <category>baseline</category>
      <category>ux</category>
    </item>
    <item>
      <title>CSS contrast-color(): text that picks its own color</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:30:39 +0000</pubDate>
      <link>https://dev.to/richicodes/css-contrast-color-text-that-picks-its-own-color-o3h</link>
      <guid>https://dev.to/richicodes/css-contrast-color-text-that-picks-its-own-color-o3h</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-24-css-contrast-color-text-that-picks-its-own-color" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You give CSS a background color and it hands back white or black, whichever reads better on it. That's &lt;code&gt;contrast-color()&lt;/code&gt;, and since April 2026 it works in Chrome, Firefox and Safari.&lt;/p&gt;

&lt;p&gt;Play with the demo above for a minute. The big card has no text color set anywhere: drag the lightness slider, or hit &lt;strong&gt;Sweep&lt;/strong&gt;, and watch the text flip from white to black at the point where black starts winning. The strip of twelve tiles below is the same idea at a glance: same lightness and chroma, twelve hues, and each tile flips at a different spot because yellow and blue don't have the same luminance at the "same" lightness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is and why it matters
&lt;/h2&gt;

&lt;p&gt;Any time a color comes from data (a tag color from a CMS, a user-picked theme, a chart series, a team's brand color) you need a text color that stays legible on top of it. Until now that meant a JavaScript helper, a Sass function at build time, or a lookup table of hand-checked pairs. &lt;code&gt;contrast-color()&lt;/code&gt; moves that decision into the browser, at computed-value time, so it also follows custom properties, &lt;code&gt;:hover&lt;/code&gt; states and theme switches.&lt;/p&gt;

&lt;p&gt;There's a real example in this very repo. The active tag chip in the notes explorer on this site uses &lt;code&gt;bg-richi-orange text-white&lt;/code&gt;, which is white on &lt;code&gt;#FF5200&lt;/code&gt;. By the WCAG 2.1 formula that pair gives 3.25:1; black on the same orange gives 6.46:1. &lt;code&gt;contrast-color(#FF5200)&lt;/code&gt; returns black. The demo starts on that exact orange so you can see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The minimal version is three lines, and it's what the demo card uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#FF5200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contrast-color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://drafts.csswg.org/css-color-5/#contrast-color" rel="noopener noreferrer"&gt;CSS Color Level 5 spec&lt;/a&gt; is short on purpose. The function takes one &lt;code&gt;&amp;lt;color&amp;gt;&lt;/code&gt; and resolves to either &lt;code&gt;white&lt;/code&gt; or &lt;code&gt;black&lt;/code&gt;, whichever produces maximum contrast with the input used as a solid background. If both tie, it returns &lt;code&gt;white&lt;/code&gt;. That's it: no target ratio, no list of candidates, no custom colors. The spec's changelog records that the function was simplified by a working group resolution, and a note says future versions are expected to add more control over the algorithm and the returned color.&lt;/p&gt;

&lt;p&gt;Two details from the spec are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The algorithm is up to the browser.&lt;/strong&gt; The spec says the exact contrast algorithm is UA-defined and advises browsers not to simply use the WCAG 2.1 contrast ratio, because of its known issues. It does require that the result still meets WCAG 2.1 AA for large text (3:1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It decides; it doesn't guarantee.&lt;/strong&gt; It picks the better of two options. It can't make a bad background good.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Out of curiosity I checked whether today's engines follow WCAG anyway. In Chromium 153 and in WebKitGTK 2.52, I compared &lt;code&gt;contrast-color()&lt;/code&gt; against the WCAG 2.1 "pick the higher ratio" rule on 2,000 random sRGB colors, and the picks matched every time in both. That's a test, not a promise: the spec allows it to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mid-tone trap
&lt;/h2&gt;

&lt;p&gt;Here's the catch MDN warns about: on mid-tones, neither white nor black is great. MDN's own example is &lt;code&gt;#2277d3&lt;/code&gt;, a royal blue that gets black text. By the WCAG formula that's 4.65:1 for black and 4.52:1 for white: both technically pass AA for normal text, both are uncomfortable for small type.&lt;/p&gt;

&lt;p&gt;There's a hard floor here too. Doing the math with the WCAG luminance formula, the best of black or white can never drop below √21 ≈ 4.58:1. So &lt;code&gt;contrast-color()&lt;/code&gt; will never fail AA outright, but on mid-tones it will hover right on that line. The demo shows both ratios live and tells you when you're above 7:1 (AAA) or only just in AA territory. The fix is still a design one: push the background lighter or darker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support, fallbacks and one gotcha
&lt;/h2&gt;

&lt;p&gt;Support according to MDN's browser compat data: Chrome 147, Firefox 146 and Safari 26. web-features marks it Baseline newly available since April 10, 2026. I ran the demo in Chromium 153 and WebKitGTK 2.52; I couldn't run Firefox in my environment.&lt;/p&gt;

&lt;p&gt;For older browsers, the classic "declare a fallback first" trick has a trap when you use a custom property. This doesn't work the way you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contrast-color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c"&gt;/* parses fine because of var() */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the value contains &lt;code&gt;var()&lt;/code&gt;, the browser accepts the second declaration at parse time without checking the function. If it then can't resolve &lt;code&gt;contrast-color()&lt;/code&gt;, the property becomes invalid at computed-value time and falls back to the inherited color, not to your &lt;code&gt;black&lt;/code&gt;. I tested this in Chromium with a made-up function name and that's exactly what happens. Use &lt;code&gt;@supports&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contrast-color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;red&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contrast-color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you need the same decision in JavaScript (the demo does this as plan B when &lt;code&gt;CSS.supports('color', 'contrast-color(red)')&lt;/code&gt; is false), the WCAG 2.1 version is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.04045&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.92&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.2126&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Same rule as contrast-color(): the one with more contrast wins, ties go to white&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pickTextColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;black&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;white&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;pickTextColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#FF5200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "black"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The next level of the function.&lt;/strong&gt; The spec already says more control over the algorithm and the returned colors is expected. If you need brand colors instead of pure black and white, that's where it would show up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The algorithm itself.&lt;/strong&gt; Since it's UA-defined and the spec discourages plain WCAG 2.1, a browser could switch to a perceptual model. Mid-tone backgrounds are where you'd notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own audits.&lt;/strong&gt; Automated checkers compute WCAG 2.1 ratios. If a browser ever picks differently from WCAG on a mid-tone, the checker and the screen could disagree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/contrast-color" rel="noopener noreferrer"&gt;contrast-color() - MDN Web Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-color-5/#contrast-color" rel="noopener noreferrer"&gt;CSS Color Module Level 5: contrast-color()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/TR/WCAG21/#contrast-minimum" rel="noopener noreferrer"&gt;WCAG 2.1: Success Criterion 1.4.3 Contrast (Minimum)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-24-css-contrast-color-text-that-picks-its-own-color" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>color</category>
      <category>a11y</category>
      <category>contrastcolor</category>
    </item>
    <item>
      <title>llms.txt v2: what an agent actually reads</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Sat, 24 Oct 2026 07:30:39 +0000</pubDate>
      <link>https://dev.to/richicodes/llmstxt-v2-what-an-agent-actually-reads-3c43</link>
      <guid>https://dev.to/richicodes/llmstxt-v2-what-an-agent-actually-reads-3c43</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-23-llms-txt-v2-what-an-agent-actually-reads" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The llms.txt proposal got its first big revision in August 2026, and the changes answer the obvious question nobody could answer before: given a page, how does an agent find the llms.txt that describes it? I read the new text, ran this site's own &lt;code&gt;/llms.txt&lt;/code&gt; through the small reference parser that lives in the proposal's repository, and it crashed. The demo above shows both things: what a parser really gets out of the file, and how an agent walks from a page to its llms.txt and its Markdown version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What llms.txt is, in one paragraph
&lt;/h2&gt;

&lt;p&gt;It's a Markdown file that gives agents a curated entry point into a site: a name, a short summary, some context and lists of links to LLM-friendly content. Jeremy Howard published the original in September 2024. It's still a proposal "open for community input", not a W3C or IETF standard. The text is clear about what it's for: robots.txt says what automated access is acceptable, while llms.txt "is instead used on demand, when an agent needs information about a topic while assisting a user". It's meant for inference, not training.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in v2
&lt;/h2&gt;

&lt;p&gt;The Changes page lists four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any path, most specific wins.&lt;/strong&gt; A file at &lt;code&gt;/docs/llms.txt&lt;/code&gt; covers everything under &lt;code&gt;/docs/&lt;/code&gt;, and "where more than one file applies, agents should use the most specific one". This is what lets a GitHub Pages project site, which can't touch the host root, take part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two Markdown URL forms.&lt;/strong&gt; v1 only had &lt;code&gt;.md&lt;/code&gt; appended (&lt;code&gt;page.html.md&lt;/code&gt;). v2 also allows replacing the extension (&lt;code&gt;page.md&lt;/code&gt;). URLs without a file name use &lt;code&gt;index.html.md&lt;/code&gt; or &lt;code&gt;index.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery through link relations.&lt;/strong&gt; &lt;code&gt;rel="alternate" type="text/markdown"&lt;/code&gt; points to a page's Markdown version and &lt;code&gt;rel="describedby"&lt;/code&gt; points to the llms.txt that covers it, either as &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; elements or as an HTTP header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;## Optional&lt;/code&gt; lost its special meaning.&lt;/strong&gt; The context-expansion tool from v1 is no longer part of the proposal, so &lt;code&gt;Optional&lt;/code&gt; is now just a convention for secondary links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The header form is the interesting one, because you can add it in your server or CDN config without touching a single page, and it also works on non-HTML files. This is the proposal's own example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Link: &amp;lt;/docs/page.html.md&amp;gt;; rel="alternate"; type="text/markdown", &amp;lt;/docs/llms.txt&amp;gt;; rel="describedby"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the equivalent in the page's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text/markdown"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/docs/page.html.md"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"describedby"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/docs/llms.txt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "How it gets found" tab in the demo builds that header for each page of a small example site. These are the two functions behind it, which I ran in Node with the outputs shown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The llms.txt files that cover a path, most specific first.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;coveringLlms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// page.html -&amp;gt; page.html.md ('append') or page.md ('replace').&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;markdownUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;append&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;append&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.[^&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;/]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/llms.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/docs/llms.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nf"&gt;coveringLlms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/docs/api/fetch.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ['/docs/llms.txt', '/llms.txt']&lt;/span&gt;
&lt;span class="nf"&gt;markdownUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/docs/api/fetch.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;replace&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// '/docs/api/fetch.md'&lt;/span&gt;
&lt;span class="nf"&gt;markdownUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/docs/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;append&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// '/docs/index.html.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Untick "/docs/llms.txt exists" and the docs pages fall back to the root file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format is stricter than it looks
&lt;/h2&gt;

&lt;p&gt;The proposal says the file is "in a precise format allowing fixed processing methods (i.e. classical programming techniques such as parsers and regex)". In order: an H1 with the name (the only required part), a blockquote with a short summary, zero or more paragraphs or lists with no headings, then H2 sections whose lists hold &lt;code&gt;[name](url)&lt;/code&gt; links, optionally followed by &lt;code&gt;:&lt;/code&gt; and notes.&lt;/p&gt;

&lt;p&gt;The repository still ships a parser of about twenty lines, &lt;code&gt;llms_txt/miniparse.py&lt;/code&gt;, and the demo's first tab is a line-for-line JavaScript port of it. I ran both on the same seven files, this site's llms.txt and the proposal's own included, and they return the same result in every case. Here is what I found by feeding it real input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;miniparse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse_llms_txt&lt;/span&gt;

&lt;span class="nf"&gt;parse_llms_txt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;# My site&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; What it is.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;One line of details.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;## Docs&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;- [Guide](https://example.com/guide.md): start here&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# {'title': 'My site', 'summary': 'What it is.', 'info': 'One line of details.',
#  'sections': {'Docs': [{'title': 'Guide', 'url': 'https://example.com/guide.md', 'desc': 'start here'}]}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every non-empty line in a section must contain a &lt;code&gt;[name](url)&lt;/code&gt; link.&lt;/strong&gt; A line like &lt;code&gt;- GitHub: https://github.com/me&lt;/code&gt; raises &lt;code&gt;AttributeError&lt;/code&gt;. So does a long list item wrapped onto a second, indented line, because the parser splits sections line by line. This site's &lt;code&gt;/llms.txt&lt;/code&gt; does both, which is why the "richi.codes today" example opens in red.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the first line of the summary counts.&lt;/strong&gt; A blockquote spread over several &lt;code&gt;&amp;gt;&lt;/code&gt; lines keeps its first line as the summary and dumps the rest into the details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The summary needs details after it.&lt;/strong&gt; With nothing between the blockquote and the first &lt;code&gt;##&lt;/code&gt;, the summary comes back as &lt;code&gt;None&lt;/code&gt; and the blockquote ends up in &lt;code&gt;info&lt;/code&gt;. With only an H1 before the first &lt;code&gt;##&lt;/code&gt;, the parser raises &lt;code&gt;AttributeError&lt;/code&gt;. Even the &lt;code&gt;llms.txt&lt;/code&gt; in the proposal's own repository comes back with &lt;code&gt;summary: None&lt;/code&gt; for this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;###&lt;/code&gt; also splits.&lt;/strong&gt; The pattern is &lt;code&gt;^##&lt;/code&gt;, so &lt;code&gt;### Getting started&lt;/code&gt; becomes a section called &lt;code&gt;# Getting started&lt;/code&gt;. The spec doesn't allow headings before the first H2 anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent reading the file as plain Markdown won't care about any of this. But if a tool parses llms.txt the way the proposal invites it to, these details decide whether your links survive. The "richi.codes fixed" example shows the cheap fix: one line per link, real Markdown links, and a short paragraph after the summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who actually consumes it.&lt;/strong&gt; The proposal lists documentation platforms that generate the file and says the AI labs publish llms.txt files for their own developer docs. Publishing a file isn't the same as reading one, so check what the agents you care about document before treating llms.txt as a ranking factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Link header.&lt;/strong&gt; It's the cheapest v2 change to adopt: one line of server config, and agents can go from any page to its Markdown version and its llms.txt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This site's file.&lt;/strong&gt; The demo makes it obvious that &lt;code&gt;/llms.txt&lt;/code&gt; here has plain-URL list items and wrapped lines. That's worth fixing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/AnswerDotAI/llms-txt/main/nbs/index.qmd" rel="noopener noreferrer"&gt;The /llms.txt file, v2 (proposal source, AnswerDotAI/llms-txt)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/AnswerDotAI/llms-txt/main/nbs/changes.qmd" rel="noopener noreferrer"&gt;Changes: v2 (August 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/AnswerDotAI/llms-txt/main/llms_txt/miniparse.py" rel="noopener noreferrer"&gt;llms_txt/miniparse.py, the reference parser&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-23-llms-txt-v2-what-an-agent-actually-reads" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmstxt</category>
      <category>ai</category>
      <category>seo</category>
      <category>markdown</category>
    </item>
    <item>
      <title>CSS shape(): responsive shapes without SVG</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:30:40 +0000</pubDate>
      <link>https://dev.to/richicodes/css-shape-responsive-shapes-without-svg-48b2</link>
      <guid>https://dev.to/richicodes/css-shape-responsive-shapes-without-svg-48b2</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-22-css-shape-responsive-shapes-without-svg" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can now draw a star, a blob or a speech bubble in &lt;code&gt;clip-path&lt;/code&gt; with percentages, &lt;code&gt;px&lt;/code&gt;, &lt;code&gt;calc()&lt;/code&gt; and custom properties, and morph between shapes with a single &lt;code&gt;transition&lt;/code&gt;: the CSS &lt;code&gt;shape()&lt;/code&gt; function has been Baseline since February 2026.&lt;/p&gt;

&lt;p&gt;For years, any &lt;code&gt;clip-path&lt;/code&gt; more interesting than &lt;code&gt;circle()&lt;/code&gt; or &lt;code&gt;polygon()&lt;/code&gt; meant &lt;code&gt;path()&lt;/code&gt;, and &lt;code&gt;path()&lt;/code&gt; takes an SVG path string where every number is a pixel. That's fine for a fixed-size icon, but it breaks the moment the element resizes: the shape doesn't scale, and you end up recalculating the string in JavaScript. &lt;code&gt;shape()&lt;/code&gt; uses the same drawing model as SVG paths, but written in CSS syntax, so each coordinate can be whatever CSS value makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shape() is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;shape()&lt;/code&gt; is a &lt;code&gt;&amp;lt;basic-shape&amp;gt;&lt;/code&gt; function, and MDN lists it for &lt;code&gt;clip-path&lt;/code&gt;, &lt;code&gt;offset-path&lt;/code&gt; and the newer &lt;code&gt;border-shape&lt;/code&gt;. It takes a starting point and a comma-separated list of commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.triangle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands mirror SVG path commands, with words instead of letters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;move&lt;/code&gt;, &lt;code&gt;line&lt;/code&gt;, &lt;code&gt;hline&lt;/code&gt; and &lt;code&gt;vline&lt;/code&gt; for jumps and straight lines.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curve ... with ...&lt;/code&gt; for Bézier curves: one control point gives a quadratic curve, two (separated by &lt;code&gt;/&lt;/code&gt;) a cubic one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;smooth&lt;/code&gt; for a curve that continues the previous one, &lt;code&gt;arc&lt;/code&gt; for elliptical arcs, and &lt;code&gt;close&lt;/code&gt; to go back to the start.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;to&lt;/code&gt; means coordinates measured from the top-left corner of the reference box; &lt;code&gt;by&lt;/code&gt; means relative to where the previous command ended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Percentages are relative to the width and height of the reference box. And according to MDN, this is the real difference from &lt;code&gt;path()&lt;/code&gt;: &lt;code&gt;path()&lt;/code&gt; is a single string limited to &lt;code&gt;px&lt;/code&gt;, while &lt;code&gt;shape()&lt;/code&gt; accepts percentages, &lt;code&gt;rem&lt;/code&gt;, &lt;code&gt;em&lt;/code&gt; and math functions such as &lt;code&gt;calc()&lt;/code&gt;, &lt;code&gt;max()&lt;/code&gt; or &lt;code&gt;abs()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixing units: the responsive bubble
&lt;/h2&gt;

&lt;p&gt;The second tab in the demo above is a speech bubble. The trick is deciding what scales and what doesn't: the corners and the tail are fixed in &lt;code&gt;px&lt;/code&gt;, while the box and the tail's position are percentages. Because &lt;code&gt;shape()&lt;/code&gt; is just CSS, the values come straight from custom properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bubble&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--arrow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hline&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vline&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;hline&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--arrow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--arrow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--arrow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;hline&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--tail&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;vline&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a walk around the edge: top edge, a quadratic curve for the top-right corner (the control point is the corner itself), down the right side, the bottom-right corner, along the bottom to the tail, down to the tip at &lt;code&gt;100%&lt;/code&gt; and back up, and the same on the left. Drag the width slider in the demo and the corners stay round instead of turning into ellipses, because their radius is in &lt;code&gt;px&lt;/code&gt;. The spec itself uses a speech bubble like this one to explain why mixing units matters.&lt;/p&gt;

&lt;p&gt;The sliders don't rebuild the string: they only change &lt;code&gt;--r&lt;/code&gt;, &lt;code&gt;--arrow&lt;/code&gt; and &lt;code&gt;--tail&lt;/code&gt; with &lt;code&gt;style.setProperty()&lt;/code&gt;. The browser redraws the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Morphing for free
&lt;/h2&gt;

&lt;p&gt;The first tab is the part that catches the eye. The specification says that two &lt;code&gt;shape()&lt;/code&gt; values (or a &lt;code&gt;shape()&lt;/code&gt; and a &lt;code&gt;path()&lt;/code&gt;) can be interpolated if their command lists are the same length and have the same commands in order. A command counts as "the same" if it uses the same keyword and the same &lt;code&gt;by&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt;, and for &lt;code&gt;curve&lt;/code&gt; and &lt;code&gt;smooth&lt;/code&gt; the same number of control points.&lt;/p&gt;

&lt;p&gt;So if you design every shape with the same skeleton, a &lt;code&gt;transition&lt;/code&gt; does the morph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clip-path&lt;/span&gt; &lt;span class="m"&gt;.6s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.badge&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the demo, the circle, the star, the flower and the random blob are all twelve &lt;code&gt;curve to X Y with CX CY&lt;/code&gt; commands generated from polar coordinates. The star simply puts each control point halfway along the segment, so the curve looks straight. Since the skeleton never changes, any shape morphs into any other.&lt;/p&gt;

&lt;p&gt;I checked the flip side in Chromium too: if I replace one of the &lt;code&gt;line&lt;/code&gt; commands in the hover state with a &lt;code&gt;curve&lt;/code&gt;, the transition stops interpolating and the shape jumps straight to the final value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support and gotchas
&lt;/h2&gt;

&lt;p&gt;According to MDN's compatibility data, &lt;code&gt;shape()&lt;/code&gt; is supported in Chrome and Edge 135, Firefox 148 and Safari 18.4. It became Baseline newly available on 24 February 2026, so it's not yet "widely available". For older browsers, feature detection is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo uses the same check in JavaScript with &lt;code&gt;CSS.supports()&lt;/code&gt;. If it fails, it falls back to &lt;code&gt;path()&lt;/code&gt; with pixel values computed from the element's size and recalculated with a &lt;code&gt;ResizeObserver&lt;/code&gt;. That's exactly the work &lt;code&gt;shape()&lt;/code&gt; saves you. You can see the fallback by adding &lt;code&gt;?fallback&lt;/code&gt; to the demo's URL.&lt;/p&gt;

&lt;p&gt;Things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;clip-path&lt;/code&gt; only clips. Shadows and borders are clipped too, so the glow in the demo is a &lt;code&gt;filter: drop-shadow()&lt;/code&gt; on the parent, which follows the clipped shape.&lt;/li&gt;
&lt;li&gt;Computed values keep the units you wrote: &lt;code&gt;getComputedStyle()&lt;/code&gt; returns something like &lt;code&gt;shape(from 0px 0px, hline to calc(100% - 20px), ...)&lt;/code&gt;, which helps when you're debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;border-shape&lt;/code&gt;&lt;/strong&gt;: it accepts &lt;code&gt;shape()&lt;/code&gt; too, but in MDN's compatibility data it only appears in Chrome 147. It's the piece that would let you shape the border as well, not just clip the element.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relative control points&lt;/strong&gt;: the spec defines &lt;code&gt;from start&lt;/code&gt;, &lt;code&gt;from end&lt;/code&gt; and &lt;code&gt;from origin&lt;/code&gt; for control points. MDN's examples use them behind their own &lt;code&gt;@supports&lt;/code&gt; check, so test them in every browser before relying on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Converting existing paths&lt;/strong&gt;: if you already have an SVG &lt;code&gt;d&lt;/code&gt; string, the translation to &lt;code&gt;shape()&lt;/code&gt; is mechanical (&lt;code&gt;M&lt;/code&gt; → &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;move&lt;/code&gt;, &lt;code&gt;L&lt;/code&gt; → &lt;code&gt;line&lt;/code&gt;, &lt;code&gt;Q&lt;/code&gt;/&lt;code&gt;C&lt;/code&gt; → &lt;code&gt;curve&lt;/code&gt;, &lt;code&gt;Z&lt;/code&gt; → &lt;code&gt;close&lt;/code&gt;), and that's when you can swap pixels for percentages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/basic-shape/shape" rel="noopener noreferrer"&gt;shape() CSS function — MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-shapes-1/#shape-function" rel="noopener noreferrer"&gt;CSS Shapes Module Level 1: the shape() function — CSSWG Editor's Draft&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/clip-path" rel="noopener noreferrer"&gt;clip-path — MDN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-22-css-shape-responsive-shapes-without-svg" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>clippath</category>
      <category>shape</category>
      <category>animation</category>
    </item>
    <item>
      <title>CSS Custom Highlight API: style text without spans</title>
      <dc:creator>richi.codes</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:30:40 +0000</pubDate>
      <link>https://dev.to/richicodes/css-custom-highlight-api-style-text-without-spans-4o47</link>
      <guid>https://dev.to/richicodes/css-custom-highlight-api-style-text-without-spans-4o47</guid>
      <description>&lt;p&gt;&lt;strong&gt;This note has an interactive demo.&lt;/strong&gt; &lt;a href="https://richi.codes/article/2026-10-21-css-custom-highlight-api-style-text-without-spans" rel="noopener noreferrer"&gt;Try it in the original&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can paint any piece of text on a page without wrapping it in a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; or a &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt;: the CSS Custom Highlight API has been Baseline since March 2026, when Firefox 149 shipped the last missing piece, the &lt;code&gt;::highlight()&lt;/code&gt; pseudo-element.&lt;/p&gt;

&lt;p&gt;Type something in the demo above. Every match turns orange, but keep an eye on the middle counter: the number of DOM nodes inside the text doesn't move. Start the read-along or flag the typos and it still doesn't move. In a browser without the API, the demo falls back to real &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; elements and you can watch that counter grow and turn orange.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The classic way to highlight search results is to rewrite the HTML: split text nodes, wrap the matches in elements, and undo it all on the next keystroke. It means extra elements in the DOM on every keystroke, and it gets messy fast when two highlights need to overlap.&lt;/p&gt;

&lt;p&gt;MDN describes the API as a way to style arbitrary text ranges by "using JavaScript to create the ranges, and CSS to style them". It extends the idea behind the highlight pseudo-elements the browser already had (&lt;code&gt;::selection&lt;/code&gt;, &lt;code&gt;::spelling-error&lt;/code&gt;, &lt;code&gt;::grammar-error&lt;/code&gt;, &lt;code&gt;::target-text&lt;/code&gt;) to ranges you define yourself. Typical uses: search in page, spell and grammar checkers, code editors, collaborative editors with one colour per user.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Four steps: create &lt;code&gt;Range&lt;/code&gt; objects, group them in a &lt;code&gt;Highlight&lt;/code&gt;, register it by name in &lt;code&gt;CSS.highlights&lt;/code&gt;, and style that name with &lt;code&gt;::highlight()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a text node&lt;/span&gt;

&lt;span class="c1"&gt;// 1. A Range for every match&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ranges&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ranges&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ranges&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2 and 3. One Highlight holds them all; the registry gives it a name&lt;/span&gt;
&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Highlight&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* 4. Only the name is shared between JS and CSS */&lt;/span&gt;
&lt;span class="nd"&gt;::highlight&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;search&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#FF5200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick in the demo's search box: on each &lt;code&gt;input&lt;/code&gt; event it walks the text nodes with a &lt;code&gt;TreeWalker&lt;/code&gt;, builds the ranges and calls &lt;code&gt;CSS.highlights.set()&lt;/code&gt; again. &lt;code&gt;CSS.highlights&lt;/code&gt; is a &lt;code&gt;Map&lt;/code&gt;-like registry, so &lt;code&gt;delete(name)&lt;/code&gt; and &lt;code&gt;clear()&lt;/code&gt; remove highlights, and a &lt;code&gt;Highlight&lt;/code&gt; is &lt;code&gt;Set&lt;/code&gt;-like, with &lt;code&gt;add()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt; and &lt;code&gt;size&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The read-along button uses the same idea with a single range that moves to the next word every 280 ms. The typos button registers a third highlight and sets its &lt;code&gt;type&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;typos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Highlight&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;typoRanges&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;typos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spelling-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// default is 'highlight'; 'grammar-error' also exists&lt;/span&gt;
&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;typo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;typos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to MDN, &lt;code&gt;type&lt;/code&gt; lets assistive technologies include that meaning when they expose the highlight, although support "may vary across platforms".&lt;/p&gt;

&lt;h3&gt;
  
  
  When highlights overlap
&lt;/h3&gt;

&lt;p&gt;In the demo the search can overlap the read-along word. Which colour wins is not decided by CSS order or &lt;code&gt;!important&lt;/code&gt;: it's decided by &lt;code&gt;Highlight.priority&lt;/code&gt;. With equal priorities, the most recently registered highlight wins for the conflicting properties. The demo gives the search &lt;code&gt;priority = 1&lt;/code&gt;, so orange stays on top. Properties that don't conflict are all applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can style
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;::highlight()&lt;/code&gt; only accepts a short list of properties: &lt;code&gt;color&lt;/code&gt;, &lt;code&gt;background-color&lt;/code&gt;, &lt;code&gt;text-decoration&lt;/code&gt; and its longhands, &lt;code&gt;text-shadow&lt;/code&gt;, and &lt;code&gt;-webkit-text-stroke-color&lt;/code&gt;, &lt;code&gt;-webkit-text-fill-color&lt;/code&gt; and &lt;code&gt;-webkit-text-stroke-width&lt;/code&gt;. &lt;code&gt;background-image&lt;/code&gt; is ignored, so no gradients. The wavy red underline in the demo is plain &lt;code&gt;text-decoration: underline wavy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Support:&lt;/strong&gt; Chrome and Edge 105, Safari 17.2, Firefox 149 for the full feature. Firefox had the JavaScript side (&lt;code&gt;Highlight&lt;/code&gt;, &lt;code&gt;CSS.highlights&lt;/code&gt;) from 140, but &lt;code&gt;::highlight()&lt;/code&gt; couldn't use &lt;code&gt;text-decoration&lt;/code&gt; before 146 or &lt;code&gt;text-shadow&lt;/code&gt; before 149.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect it:&lt;/strong&gt; &lt;code&gt;'highlights' in CSS&lt;/code&gt; is the basic check. The demo checks that and &lt;code&gt;typeof Highlight === 'function'&lt;/code&gt;, shows a warning and falls back to &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; elements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari and &lt;code&gt;user-select: none&lt;/code&gt;:&lt;/strong&gt; browser-compat-data notes that in Safari the highlight style is ignored when combined with &lt;code&gt;user-select: none&lt;/code&gt; (WebKit bug 278455). Don't put highlights on text you've made unselectable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No semantics by default:&lt;/strong&gt; MDN is clear that custom highlights don't add semantic meaning to the document; &lt;code&gt;type&lt;/code&gt; only covers spelling and grammar errors, and support for it varies. If the highlight carries meaning a screen reader user needs, MDN suggests semantic HTML such as &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; or another accessible cue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ranges point at text nodes.&lt;/strong&gt; If your framework re-renders and replaces those nodes, recompute the ranges. The demo's fallback does exactly that after each reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to keep an eye on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Older Firefox:&lt;/strong&gt; until your audience is on 149+, test &lt;code&gt;text-shadow&lt;/code&gt; and &lt;code&gt;text-decoration&lt;/code&gt; there, or stick to &lt;code&gt;color&lt;/code&gt; and &lt;code&gt;background-color&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility:&lt;/strong&gt; use &lt;code&gt;type&lt;/code&gt; where it fits, and don't rely on colour alone for meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editors and search:&lt;/strong&gt; in-page search, spell checking and collaborative cursors are the obvious places where this replaces a lot of DOM juggling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API" rel="noopener noreferrer"&gt;CSS Custom Highlight API - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::highlight" rel="noopener noreferrer"&gt;::highlight() - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Highlight/priority" rel="noopener noreferrer"&gt;Highlight: priority property - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://drafts.csswg.org/css-highlight-api-1/" rel="noopener noreferrer"&gt;CSS Custom Highlight API Module Level 1 - W3C Editor's Draft&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://richi.codes/article/2026-10-21-css-custom-highlight-api-style-text-without-spans" rel="noopener noreferrer"&gt;richi.codes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>javascript</category>
      <category>highlight</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
