<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Bram.us</title>
	<atom:link href="https://www.bram.us/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.bram.us</link>
	<description>A rather geeky/technical weblog, est. 2001, by Bramus</description>
	<lastBuildDate>Wed, 23 Sep 2026 08:35:42 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.2</generator>
	<item>
		<title>Show keystrokes on a website with the &#x3C;&#x200B;show-keystrokes&#x3E; custom element</title>
		<link>https://www.bram.us/2026/09/23/show-keystrokes/</link>
					<comments>https://www.bram.us/2026/09/23/show-keystrokes/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Wed, 23 Sep 2026 08:32:27 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[custom elements]]></category>
		<category><![CDATA[keypress]]></category>
		<category><![CDATA[shortcuts]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36850</guid>

					<description><![CDATA[<p>When recording screen casts or doing live demos, I typically rely on a native macOS app to visualize my keystrokes on screen. Recently, however, that app got blocked on my work machine by a corporate security policy. Additionally, the keystrokes visualized through that app don’t show up when screen sharing a specific window or tab.</p><p>Because I needed to demo a website that relies heavily on keyboard navigation — and thus <em>really</em> needed those keystrokes to be visible to the audience — I found myself in a bit of a pickle. So with the help of Gemini, I decided to build a web-native solution to fill that gap: <a href="https://show-keystrokes.netlify.app/"><code>&#60;show-keystrokes&#62;</code></a></p>]]></description>
										<content:encoded><![CDATA[<figure>
	<div style="width: 640px;" class="wp-video"><video class="wp-video-shortcode" id="video-36850-1" width="640" height="411" loop autoplay muted preload="metadata" controls="controls"><source type="video/mp4" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/show-keystrokes.mp4?_=1" /><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/show-keystrokes.mp4">https://www.bram.us/wordpress/wp-content/uploads/2026/09/show-keystrokes.mp4</a></video></div><figcaption>Recording of the <a href="https://show-keystrokes.netlify.app/"><code>&lt;show-keystrokes&gt;</code> Demo &amp; Playground</a>.</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="intro">
<p>When recording screen casts or doing live demos, I typically rely on a native macOS app to visualize my keystrokes on screen. Recently, however, that app got blocked on my work machine by a corporate security policy. Additionally, the keystrokes visualized through that app don’t show up when screen sharing a specific window or tab.</p>
<p>Because I needed to demo a website that relies heavily on keyboard navigation — and thus <em>really</em> needed those keystrokes to be visible to the audience — I found myself in a bit of a pickle. So with the help of Gemini, I decided to build a web-native solution to fill that gap: <a href="https://show-keystrokes.netlify.app/"><code>&lt;show-keystrokes&gt;</code></a></p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#usage" name="usage">#</a> Embed it and you’re good to go</h3>
<p>Using <code>&lt;show-keystrokes&gt;</code> is as simple as loading the module from npm (or a CDN) and dropping the custom element onto your page. Embed it on your site and you’re good to go:</p>
<pre><code class="language-html" style="tab-size: 2">&lt;script type="module" src="https://cdn.jsdelivr.net/npm/show-keystrokes/index.js"&gt;&lt;/script&gt;
&lt;show-keystrokes&gt;&lt;/show-keystrokes&gt;</code></pre>
<p>Out of the box, <code>&lt;show-keystrokes&gt;</code> listens for keyboard events and displays keyboard shortcuts (such as <code>⌘ + A</code> or <code>⇧ + ⌘ + T</code>) and navigational keys (such as <code>⇥</code>, <code>→</code>, <code>←</code>, <code>↵</code>, and <code>⎋</code>), while ignoring regular typing. If you <em>do</em> want to show every single key press, you can set the <code>keystrokes</code> attribute to <code>all</code> (or narrow it down to <code>shortcuts</code> or <code>navigational</code>).</p>
<pre><code class="language-html" style="tab-size: 2">&lt;script type="module" src="https://cdn.jsdelivr.net/npm/show-keystrokes/index.js"&gt;&lt;/script&gt;
&lt;show-keystrokes keystrokes="all"&gt;&lt;/show-keystrokes&gt;</code></pre>
<p>The component automatically adapts to your platform (showing <code>⌘</code> and <code>⌥</code> on macOS versus <code>CTRL</code> and <code>ALT</code> on Windows/Linux), supports both symbol glyphs and text labels (<code>⌘</code> vs <code>CMD</code>), and ships with two built-in themes that respond to light and dark mode via <code>light-dark()</code>. And oh, it’s fully styleable through by targeting the exposed <code>::part()</code>s and custom properties.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#positioning-and-modern-css" name="positioning-and-modern-css">#</a> Powered by the Popover API and CSS Anchor Positioning</h3>
<p>Depending on what you are demoing, you might want the keycaps to appear in different places. Using the <code>position</code> attribute, you can easily position the component in a corner of the viewport (the default is <code>viewport top right</code>), right next to the cursor (<code>position="pointer"</code>), or just inline in normal document flow (<code>position="normal"</code>).</p>
<pre><code class="language-html" style="tab-size: 2">&lt;!-- Fixed in the bottom-center of the viewport --&gt;
&lt;show-keystrokes position="viewport bottom center"&gt;&lt;/show-keystrokes&gt;

&lt;!-- Follow the cursor --&gt;
&lt;show-keystrokes position="pointer"&gt;&lt;/show-keystrokes&gt;

&lt;!-- Render inline in normal document flow --&gt;
&lt;show-keystrokes position="normal"&gt;&lt;/show-keystrokes&gt;</code></pre>
<p>Under the hood, <code>&lt;show-keystrokes&gt;</code> leans heavily on modern web platform features to make this work seamlessly without any <code>z-index</code> battles or JavaScript bounding-box math.</p>
<p>First, the component always stays on top of everything else on the page thanks to the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Popover_API">Popover API</a>. Its internal container is promoted to the browser’s Top Layer using <code>popover="manual"</code>, ensuring that no matter how complex the host page’s stacking contexts are, your keystrokes will never be clipped or hidden behind another element.</p>
<p>Second, the component positions and repositions itself thanks to <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning">CSS Anchor Positioning</a>. In viewport mode, it anchors itself inside a full-viewport anchor element and aligns itself using <code>position-area</code>, <code>align-self</code>, and <code>justify-self</code>. In <code>pointer</code> mode, an invisible anchor tracks your cursor coordinates <em>(via custom properties)</em>, and the popover anchors itself next to it using <code>position-area</code>. The <code>position-try-fallbacks: flip-inline, flip-block, flip-inline flip-block</code> are set by default, so the browser automatically flips the keystroke overlay to the other side of the cursor whenever you move it too close to the edge of the viewport. 🙂</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#bookmarklet-and-extension" name="bookmarklet-and-extension">#</a> Run it on any site with the Bookmarklet or Chrome Extension</h3>
<p>Because you don’t always control the source code of the site you want to demo, the project also comes with both a <a href="https://show-keystrokes.netlify.app/#bookmarklet"><em>bookmarklet</em></a> <a href="https://en.wikipedia.org/wiki/Bookmarklet">ⓘ</a> and a <a href="https://show-keystrokes.netlify.app/#extension">Chrome Extension</a> so you can inject <code>&lt;show-keystrokes&gt;</code> onto any webpage on the fly.</p>
<p>The bookmarklet is a quick drag-and-drop button (available on <a href="https://show-keystrokes.netlify.app/#bookmarklet">the demo page</a>) that dynamically imports the package from jsDelivr and calls the programmatic <code>create()</code> helper. For sites with a strict Content Security Policy (CSP) or when you want <code>&lt;show-keystrokes&gt;</code> to persist across page navigations, the <a href="https://chromewebstore.google.com/detail/show-keystrokes/djfbngdmoohldepblnidecjhmgommdmh">Chrome Extension</a> <em>(currently pending review on the Chrome Web Store)</em> has you covered. It bundles the component locally, lets you configure global defaults as well as per-origin overrides right from the popup, and includes a handy <code>⇧ + ⌘ + K</code> (or <code>⇧ + CTRL + K</code>) shortcut to toggle it on or off.</p>
<p>The Chrome Extension was a bit of an afterthought once I had version 1.0.0 of the component ready. But thanks to Modern Web Guidance and <a href="https://github.com/GoogleChrome/modern-web-guidance-src/blob/main/skills-src/chrome-extensions/SKILL.md">its Chrome Extensions skill</a>, creating it was easy-peasy and done in no time <em>(also see <a href="https://developer.chrome.com/docs/extensions/ai/build-with-ai">this guide</a>)</em> 🙂</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#links" name="links">#</a> Links</h3>
<ul>
<li><a href="https://show-keystrokes.netlify.app/"><code>&lt;show-keystrokes&gt;</code> Live Demo &amp; Playground &rarr;</a></li>
<li><a href="https://github.com/bramus/show-keystrokes"><code>&lt;show-keystrokes&gt;</code> on GitHub &rarr;</a></li>
<li><a href="https://www.npmjs.com/package/show-keystrokes"><code>show-keystrokes</code> on npm &rarr;</a></li>
<li><a href="https://chromewebstore.google.com/detail/show-keystrokes/djfbngdmoohldepblnidecjhmgommdmh"><code>&lt;show-keystrokes&gt;</code> Chrome Extension &rarr;</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#spread-the-word" name="spread-the-word">#</a> Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3mw3xrnstjk2m">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117314107823933436">🦣 Mastodon</a></li>
<p>	<!-- 

<li><a href="https://lnkd.in/p/…">💼 LinkedIn</a></li>

 -->
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/23/show-keystrokes/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		<enclosure url="https://www.bram.us/wordpress/wp-content/uploads/2026/09/show-keystrokes.mp4" length="4210288" type="video/mp4" />

			</item>
		<item>
		<title>New in Chrome 154: iframes that automatically resize themselves to their content</title>
		<link>https://www.bram.us/2026/09/23/responsive-iframes/</link>
					<comments>https://www.bram.us/2026/09/23/responsive-iframes/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Tue, 22 Sep 2026 22:15:05 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[iframe]]></category>
		<category><![CDATA[resize]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36670</guid>

					<description><![CDATA[<p>Chrome 154 adds support for <em>responsively-sized iframes</em>, letting an <code>&#60;iframe&#62;</code> size itself based on the intrinsic size of its embedded document. This is perfect for seamlessly embedding third-party comment widgets, varying-height social media embeds, or any other type of embed that uses an <code>&#60;iframe&#62;</code>.</p>]]></description>
										<content:encoded><![CDATA[<p><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes.png"><img fetchpriority="high" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes.png" alt="" width="560" height="440" class="alignnone size-medium wp-image-36839" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes.png 1786w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes-560x440.png 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes-1120x879.png 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes-768x603.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes-1536x1206.png 1536w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/responsive-iframes-1568x1231.png 1568w" sizes="(max-width: 560px) 100vw, 560px" /></a></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="intro">
<p>Chrome 154 adds support for <em>responsively-sized iframes</em>, letting an <code>&lt;iframe&gt;</code> size itself based on the intrinsic size of its embedded document. This is perfect for seamlessly embedding third-party comment widgets, varying-height social media embeds, or any other type of embed that uses an <code>&lt;iframe&gt;</code>.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#the-code" name="the-code">#</a> The Code</h3>
<p>To make this work there is a two-way opt-in:</p>
<ul>
<li>The embedder (e.g. <code>index.html</code>) needs to set the <code>frame-sizing</code> CSS property with a value of <code>auto</code>, <code>content-height</code>, or <code>content-width</code> (or its logical variants) on the <code>iframe</code>.</li>
<li>The document loaded in the <code>&lt;iframe&gt;</code> (e.g. <code>iframe.html</code>) needs to include a meta tag to indicate that it’s OK to communicate its size the parent embedder.</li>
</ul>
<p>In code:</p>
<pre><code class="language-css">/* In styles.css for index.html */
iframe {
  frame-sizing: content-height;
  width: 100%;
}</code></pre>
<pre><code class="language-html">&#x3C;!-- In the iframe.html’s head --&#x3E;
&#x3C;meta name=&#x22;responsive-embedded-sizing&#x22; content=&#x22;allow-origins=*&#x22;&#x3E;</code></pre>
<p>The content’s size is communicated from the embedded document after page load. To communicate a new size, the document in the frame must call <code>window.requestResize();</code></p>
<p>Use <code>allow-origins</code> to limit which origins the document communicates its size info to.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#demo" name="demo">#</a> Demo</h3>
<p>Here’s a demo that contains a form which is an <code>&lt;iframe&gt;</code>. Whenever you go to a next step of the form, the <code>&lt;iframe&gt;</code> resizes itself to its contents … if your browser suppports <code>frame-sizing</code> that is.</p>
<p><iframe height="900" style="width: 100%;" scrolling="no" title="Responsively Sized &lt;iframe&gt; demo" src="https://codepen.io/editor/bramus/embed/01a0621c-73e0-7dd0-aef8-1a820116c459?default-tab=result" frameborder="no" loading="lazy" allowtransparency="true"><br />
  See the Pen <a href="https://codepen.io/editor/bramus/pen/01a0621c-73e0-7dd0-aef8-1a820116c459"><br />
  Responsively Sized &lt;iframe&gt; demo</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br />
  on <a href="https://codepen.io">CodePen</a>.<br />
</iframe></p>
<p>Because the iframe resizes itself, the form is seamlessly embedded, without any scrollbar appearing at all.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#browser-support" name="browser-support">#</a> Browser Support</h3>
<div class="note note--info">
<p>💡 Although this post was originally published in September 2026, the list below is constantly being updated. <em>Last update: September 26, 2026</em>.</p>
</div>
<p>Support for responsively size iframes is this:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>✅ Supported in Chrome 154</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>❌ No Support. There is no bug tracking this yet.</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>❌ No Support. There is no bug tracking this yet.</p>
</dd>
</dl>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#learn-more" name="learn-more">#</a> Learn More</h3>
<p>Get all the details about responsive iframes on <code>developer.chrome.com</code>, in this article I co-authored: <a href="https://developer.chrome.com/blog/responsive-iframes">Responsive iframes in Chrome 154 &rarr;</a></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/23/responsive-iframes/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Ship cleaner packages (without the ./dist or ./src folder) by publishing a subfolder to NPM</title>
		<link>https://www.bram.us/2026/09/20/npm-publish-subfolder/</link>
					<comments>https://www.bram.us/2026/09/20/npm-publish-subfolder/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Sun, 20 Sep 2026 17:38:20 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[npm]]></category>
		<category><![CDATA[packages]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36662</guid>

					<description><![CDATA[<p>One detail that has always bugged me about publishing a package to NPM is how build output paths like <code>dist/</code> can leak into the public API.</p><p>Turns out, <code>npm publish</code> can do it, but to make it work seamlessly in a real-world project, you need a few extra pieces in place. Let me walk you through how it works.</p>]]></description>
										<content:encoded><![CDATA[<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder.png"><img decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder.png" alt="" width="560" height="315" class="alignnone size-medium wp-image-36825" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder.png 2400w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-560x315.png 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-1120x630.png 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-768x432.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-1536x864.png 1536w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-2048x1152.png 2048w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/npm-publish-subfolder-1568x882.png 1568w" sizes="(max-width: 560px) 100vw, 560px" /></a><figcaption>Visual of the build steps to take. Note that before running <code>npm publish ./dist</code> you need to prepare the files in <code>./dist</code> <em>(as detailed in this post)</em></figcaption></figure>
<div class="intro">
<p>One detail that has always bugged me about publishing a package to NPM is how build output paths like <code>dist/</code> can leak into the public API.</p>
<p>Turns out, <code>npm publish</code> can do it, but to make it work seamlessly in a real-world project, you need a few extra pieces in place. Let me walk you through how it works.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>The Need: Clean imports without leaking implementation details</h3>
<p>When authoring a package, you typically have a directory layout like this:</p>
<pre><code>my-package/
├── src/
│   └── index.js
├── dist/
│   └── index.js
├── package.json
└── README.md</code></pre>
<p>Source code lives in <code>src/</code>, while your a build step typically spits out the distribution files into a <code>dist/</code> or <code>build/</code> folder. Or if your package has no build step, because you authored an ES Module in JavaScript, you can even ship <code>src/</code> as-is. This detail can leak into your public API, which is something you generally want to avoid, especially when loading the files from a CDN.</p>
<p>For example, the very first version of <a href="https://bsky.app/profile/bram.us/post/3ldiynl2d722i">my <code>ie-page-transitions</code> package</a> — which has no build step — shipped the <code>src</code> folder as part of the package structure.</p>
<pre><code>my-package/
├── src/
│   ├── ie-page-transitions.css
│   ├── ie-page-transitions.mpa.js
│   ├── ie-page-transitions.shared.js
│   └── ie-page-transitions.spa.js
├── package.json
└── README.md</code></pre>
<p>To users that use NodeJS (or a bundler) this detail is not visible, thanks to the <code>exports</code> field in the <code>package.json</code>.</p>
<pre><code>{
  "name": "ie-page-transitions",
  "version": "0.0.1",
  …
  "exports": {
    "./spa": "./src/ie-page-transitions.spa.js",
    "./mpa": "./src/ie-page-transitions.mpa.js",
    "./shared": "./src/ie-page-transitions.shared.js",
    "./css": "./src/ie-page-transitions.css"
  },
  …
}</code></pre>
<p>When using NodeJS, users can just import the files straight from the package without needing to know the files live in the <code>./src</code> folder. For example, the CSS can be imported as follow:</p>
<pre><code class="language-javascript">import styles from 'ie-page-transitions/css' with { type: "css" };</code></pre>
<p>But for users loading the package from a CDN, it’s a different story. When loading the package from a CDN like jsDelivr, users needed to import the files from locations that all include that <code>src</code> folder. That same CSS file for example needs to be loaded from this location:</p>
<pre><code>https://cdn.jsdelivr.net/npm/ie-page-transitions@0.0.1<strong>/src</strong>/ie-page-transitions.css</code></pre>
<p>I don’t like this, because <mark>having <code>src/</code> (or <code>dist/</code> or <code>build/</code>) in public import paths is an implementation detail that leaked into the public API</mark>. It looks clunky, exposes the internal folder structure, and is just annoying to type.</p>
<div class="note">
<p>I’m not the only one here. A quick search tells me that developers <a href="https://stackoverflow.com/questions/38935176/how-to-npm-publish-specific-folder-but-as-package-root">have been asking about this for more than 10 years by now</a>. PNPM actually supports a <a href="https://pnpm.io/package_json#publishconfigdirectory"><code>publishConfig.directory</code></a> setting in the <code>package.json</code> file to achieve this, but stock NPM doesn’t.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>The Solution: <code>npm publish ./folder</code></h3>
<p>For my last three packages (<a href="https://brm.us/hic-pageflip"><code>hic-pageflip</code></a>, <a href="https://brm.us/mermaid-element"><code>mermaid-element</code></a>, and <a href="https://brm.us/rich-input"><code>rich-input</code></a>), which all build their source into a <code>./dist</code> folder, I’ve been using a little trick to <mark>publish only <em>the contents</em> of the <code>dist</code> folder to NPM</mark>. This makes the import paths clean and direct. For example, for <code>hic-pageflip</code>, when loading from a CDN like jsDelivr, it does not include <code>dist/</code>-part in the URL:</p>
<pre><code class="language-javascript">import { PageFlip } from 'https://cdn.jsdelivr.net/npm/hic-pageflip';</code></pre>
<p>To achieve this, I used a lesser-known capability of the npm CLI: <mark><code>npm publish</code> accepts a folder path as an argument.</mark></p>
<pre class="language-bash"><code class="language-bash">npm publish ./dist</code></pre>
<p>When you pass a folder path to <code>npm publish</code>, npm doesn&#8217;t package the current working directory. Instead, <strong>it treats that target folder as the package root</strong>.</p>
<p>Whatever is inside <code>./dist</code> will be placed directly at the top level of the tarball uploaded to the registry. </p>
<p>For this to work, you do need some extra preparation, but it&#8217;s worth it. Let me walk you through how it works.</p>
<div class="note">
<p>💁‍♂️ <em>“What about just reorganizing the project to not have a <code>dist</code> folder at all?”</em></p>
<p>A workaround I’ve seen developers use — see <a href="https://github.com/keithamus/invokers-polyfill">the invokers polyfill</a> as an example — is leaving all source files in the project root and dumping build output directly into that same project root. While that works, I believe that creates a mess: compiled <code>.js</code>, <code>.d.ts</code>, and <code>.map</code> files sit right alongside your dotfiles, test configurations, and source files, cluttering git and your working directory.</p>
<p>What you really want is simple: keep the actual source code in <code>./src</code> and keep <code>./dist</code> for build outputs, but have the contents of <code>./dist</code> become the root of the package when published to NPM.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Making It Work: The Missing Pieces</h3>
<p>In order to publish the <code>dist</code> folder, there are two main things you need to do once your project has been built:</p>
<ol>
<li>Copy other necessary package files (<code>package.json</code>, <code>README.md</code>, <code>LICENSE</code>, etc.) into <code>./dist</code>.</li>
<li>Rewrite the <code>package.json</code> in <code>./dist</code> so its paths match the new root (and optionally remove the <code>scripts</code> from that file as well).</li>
</ol>
<p>Let’s look at both steps.</p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>1. Copy files you want to package into <code>./dist</code></h4>
<p>Because <code>npm publish ./dist</code> treats <code>./dist</code> as the package root and ignores everything outside of it, that folder needs to contain all the metadata files required for a valid package alongside your built code:</p>
<ul>
<li><code>package.json</code> (so npm knows your package name, version, dependencies, and entry points).</li>
<li><code>README.md</code> (so npmjs.com displays your documentation).</li>
<li><code>LICENSE</code> (so npm recognizes your license and consumers stay legally safe).</li>
</ul>
<p>Since all of these typically live in your project root, you need to copy them into <code>./dist</code> after building. You can also include other files if you want, of course.</p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>2. Rewrite the <code>package.json</code> in <code>./dist</code></h4>
<p>Simply copying your root <code>package.json</code> into <code>./dist</code> as-is won’t work — the <code>package.json</code> in the build folder needs to be adjusted.</p>
<p>In your root <code>package.json</code>, your entry points typically include the <code>./dist/</code> prefix:</p>
<pre class="language-json"><code class="language-json">{
  "name": "my-package",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    "./submodule": "./dist/submodule.js"
  }
}</code></pre>
<p>If you copy that <code>package.json</code> as-is into <code>./dist</code> and publish it, everything breaks because for published package, the root <em>is</em> the contents of <code>./dist</code> itself.</p>
<p>So the paths in the file <code>./dist/package.json</code> must be rewritten relative to the new root:</p>
<pre class="language-json"><code class="language-json">{
  "name": "my-package",
  "version": "1.0.0",
  "main": "./index.js",
  "module": "./index.js",
  "exports": {
    ".": "./index.js",
    "./submodule": "./submodule.js"
  }
}</code></pre>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>The Code: Assembling <code>./dist</code></h3>
<p>Rather than manually copying files and re-mapping every single export field by hand, you can automate both steps in a small Node.js script. For the <code>package.json</code> adjustments, a quick string replace does the trick: take your root <code>package.json</code>, strip out internal <code>scripts</code>, and replace <code>./dist/</code> with <code>./</code>.</p>
<p>Here’s a clean, zero-dependency Node.js script (<code>scripts/postbuild.js</code>) that does the heavy lifting:</p>
<pre class="language-javascript"><code class="language-javascript">import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const srcDir = path.join(rootDir, 'src');

console.log('Preparing ./dist package...');

// 1. Clean and recreate the dist directory (if not already handled by your bundler)
fs.rmSync(distDir, { recursive: true, force: true });
fs.mkdirSync(distDir, { recursive: true });

// 2. Copy compiled/source files into dist/
fs.cpSync(srcDir, distDir, { recursive: true });

// 3. Copy essential metadata files from root to dist/
const filesToCopy = ['README.md', 'LICENSE'];
for (const file of filesToCopy) {
  const srcPath = path.join(rootDir, file);
  if (fs.existsSync(srcPath)) {
    fs.copyFileSync(srcPath, path.join(distDir, file));
  }
}

// 4. Prepare and rewrite package.json for dist/
const pkgPath = path.join(rootDir, 'package.json');
if (fs.existsSync(pkgPath)) {
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));

  // Strip scripts so consumers don't get internal build/test scripts
  delete pkg.scripts;

  // Replace ./dist/ with ./ so all paths point to the root of the published package
  const distPkgContent = JSON.stringify(pkg, null, 2).replaceAll('./dist/', './') + 'n';

  // Write out the tailored package.json to dist/
  fs.writeFileSync(
    path.join(distDir, 'package.json'),
    distPkgContent,
    'utf8'
  );
}

console.log('Successfully prepared ./dist for publishing!');</code></pre>
<p>You can (and should) set this up as a <code>postbuild</code> step in your root <code>package.json</code> so that it automatically runs every time <code>npm run build</code> finishes:</p>
<pre class="language-json"><code class="language-json">{
  "scripts": {
    "build": "…",
    "postbuild": "node scripts/postbuild.js"
  }
}</code></pre>
<p>Now, every time you run <code>npm run build</code>, the files and code in <code>./dist</code> end up as a self-contained, publication-ready package.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#no-npm-publish" name="no-npm-publish">#</a> Safeguarding Against Muscle Memory: Prevent <code>npm publish</code> from running</h3>
<p>Typically, when authoring a package that has a build step, you have <code>npm run build</code> execute in a <code>prepublishOnly</code> task in your <code>package.json</code>:</p>
<pre class="language-json"><code class="language-json">{
  "scripts": {
    "build": "…",
    "postbuild": "node scripts/postbuild.js",
    "prepublishOnly": "npm run build"
  }
}</code></pre>
<p>That way, every time you run <code>npm publish</code>, npm automatically builds the project first before publishing.</p>
<p>However, with our subfolder setup, there’s a big catch here: developer muscle memory.</p>
<p>When you’re ready to ship an update and instinctively type <code>npm publish</code> into your terminal, npm will dutifully run <code>prepublishOnly</code> and execute <code>npm run build</code> … but then it’s still gonna try and publish the project root instead of <code>./dist</code> … uh-oh!</p>
<p>To prevent this from ever happening, you can repurpose <code>prepublishOnly</code> into a safeguard and combine it with a dedicated publish script (<code>npm run pub</code>) that runs <code>npm run build</code> before calling <code>npm publish ./dist</code>.</p>
<p>In your root <code>package.json</code>:</p>
<pre class="language-json"><code class="language-json">{
  "scripts": {
    "build": "…",
    "postbuild": "node scripts/postbuild.js",
    "prepublishOnly": "if [ -z $PUBLISH ]; then echo 'n🛑 ERROR: This package must be published using `npm run pub`n' &amp;&amp; exit 1; fi",
    "pub": "PUBLISH=true npm run build &amp;&amp; npm publish ./dist"
  }
}</code></pre>
<p>The trick here is the <code>$PUBLISH</code> environment variable. When you run <code>npm publish</code> directly, <code>$PUBLISH</code> is not set, and the script simply exits — preventing <code>npm publish</code> from running.</p>
<p>In the <code>pub</code> script (invoked via <code>npm run pub</code>), <code>$PUBLISH</code> gets explicitly set to <code>true</code>, which allows the build and <code>npm publish ./dist</code> to go through without being blocked.</p>
<p>This setup is simple, elegant, and completely foolproof 🙂</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Testing Your Publish: Dry Runs and Tarballs</h3>
<p>Before you publish a package to the live registry for the first time with this setup, don&#8217;t just push it and hope for the best. Test it locally first.</p>
<p>Here are two essential commands to verify your setup:</p>
<h4>1. Perform a dry run with <code>npm publish ./dist --dry-run</code></h4>
<p>You can run <code>npm publish</code> with the <code>--dry-run</code> flag pointing to your folder:</p>
<pre class="language-bash"><code class="language-bash">npm publish ./dist --dry-run</code></pre>
<p>This runs the entire publishing pipeline — tarball creation, file filtering, checksum generation — without actually uploading anything to NPM.</p>
<p>It outputs a detailed manifest of everything that will be shipped:</p>
<pre><code>npm notice === Tarball Contents ===
npm notice 1.1kB  LICENSE
npm notice 1.8kB  README.md
npm notice 850B   index.js
npm notice 1.2kB  package.json
npm notice 4.2kB  core/engine.js
npm notice === Tarball Details ===
npm notice name:          hic-pageflip
npm notice version:       1.0.1
npm notice filename:      hic-pageflip-1.0.1.tgz
npm notice package size:  3.2 kB
npm notice unpacked size: 9.1 kB
npm notice total files:   5</code></pre>
<p>Look closely at that file list: notice how <code>index.js</code>, <code>README.md</code>, and <code>LICENSE</code> are all sitting right at the root, with no <code>dist/</code> prefix in sight? That&#8217;s your confirmation that the subfolder structure is working as intended.</p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>2. Inspect the created tarball with <code>npm pack</code></h4>
<p>The previous step already generates the tarball for publishing. You can also run <code>npm pack</code> on its own to generate a local tarball (e.g. <code>my-package-1.0.0.tgz</code>) for inspection.</p>
<pre class="language-bash"><code class="language-bash"># Be sure to run npm run build first
npm pack ./dist</code></pre>
<p>You can then extract and inspect the contents of the generated tarball:</p>
<pre class="language-bash"><code class="language-bash">tar -xzf my-package-1.0.0.tgz ./my-package-1.0.0</code></pre>
<p>Now you&#8217;ll see the contents of <code>./dist</code> sitting right in the folder <code>./my-package-1.0.0</code> which confirms that the package was correctly packaged and is ready for publishing.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>In Closing</h3>
<p>Publishing a subfolder instead of your repository root is one of those small tweaks that, I think, makes for a better package, without much added complexity. I’ve successfully used this pattern in multiple projects, including <a href="https://brm.us/hic-pageflip"><code>hic-pageflip</code></a> and <a href="https://brm.us/rich-input"><code>rich-input</code></a>.</p>
<p><mark>It feels a bit stupid that NPM does not have this functionality built in</mark>, especially since developers <a href="https://stackoverflow.com/questions/38935176/how-to-npm-publish-specific-folder-but-as-package-root">have been asking about this for more than 10 years by now</a>. A new <code>publishDirectory</code> field in the <code>package.json</code> that instructs <code>npm publish</code> to publish that specific subfolder would be welcome here. PNPM has this (<a href="https://pnpm.io/package_json#publishconfigdirectory"><code>publishConfig.directory</code></a>), and I think it’s long overdue for NPM to support this as well …</p>
<p><mark>Until then, you can use the pattern I detailed here in this post 🙂</mark></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Spread the word</h3>
<p>Feel free to reshare this post on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3mvxqk2gl5k2g">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117304591336096060">🐘 Mastodon</a></li>
<li><a href="https://lnkd.in/p/en8QPRNN">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/20/npm-publish-subfolder/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Introducing &#x3C;&#x200B;rich-input&#x3E;, a GitHub-like search/filter text input to embed on your site</title>
		<link>https://www.bram.us/2026/09/14/introducing-rich-input-a-github-like-search-filter-text-input-to-embed-on-your-site/</link>
					<comments>https://www.bram.us/2026/09/14/introducing-rich-input-a-github-like-search-filter-text-input-to-embed-on-your-site/#comments</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Mon, 14 Sep 2026 21:38:37 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[custom elements]]></category>
		<category><![CDATA[forms]]></category>
		<category><![CDATA[opaquerange]]></category>
		<category><![CDATA[search]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36750</guid>

					<description><![CDATA[<p>While working on a side-project to better navigate and search through my record collection, I found myself in need of a rich search input: one that accepts free-form text alongside structured filters like <code>artist:&#8220;Aphex Twin&#8221;</code> or <code>label:&#8220;Warp Records&#8221;</code> — You know, like the one you see on GitHub when searching/filtering issues. </p><p>When I quickly realized a standalone custom element for this didn’t exist yet, I nerd sniped myself into <del>building</del> <ins>curating</ins> one.</p>]]></description>
										<content:encoded><![CDATA[<figure>
  <a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot.png"><img decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot.png" alt="" width="560" height="145" class="alignnone size-medium wp-image-36752" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot.png 1914w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot-560x145.png 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot-1120x289.png 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot-768x198.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot-1536x396.png 1536w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-screenshot-1568x405.png 1568w" sizes="(max-width: 560px) 100vw, 560px" /></a><figcaption>Screenshot of <code>&lt;rich-input&gt;</code> in action</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="intro">
<p>While working on a side-project to better navigate and search through my record collection, I found myself in need of a rich search input: one that accepts free-form text alongside structured filters like <code>artist:&#8220;Aphex Twin&#8221;</code> or <code>label:&#8220;Warp Records&#8221;</code> — You know, like the one you see on GitHub when searching/filtering issues. </p>
<p>When I quickly realized a standalone custom element for this didn’t exist yet, I nerd sniped myself into <del>building</del> <ins>curating</ins> one.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#the-need" name="the-need">#</a> The need for a rich search input</h3>
<p>If you have ever used search fields on GitHub, Gmail, or modern music databases, you know the pattern: you can type regular free-text words, but you can also narrow things down using structured <code>key:value</code> pairs. For my record collection side-project, I wanted exactly that kind of search engine-grade filter intelligence.</p>
<figure>
<a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/github-rich-search-filter.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/github-rich-search-filter.png" alt="" width="560" height="174" class="alignnone size-medium wp-image-36775" /></a><figcaption>Screenshot of GitHub’s rich search/filter field showing the User popover you get after having typed <code>author:</code></figcaption></figure>
<p>I wanted to be able to type queries such as <code>ambient artist:"Aphex Twin" label:"Warp Records"; year:2024</code>, complete with autocompletion for both the filter keywords and their allowed values. On top of that, I wanted <mark>in-input syntax highlighting for the structured filter values</mark>, without relying on heavy frontend frameworks or breaking standard form behaviors.</p>
<p>Looking around a bit, it seemed like an easy-to-use, standalone custom element providing this out of the box simply did not exist yet. So I nerd sniped myself into creating one: <a href="https://rich-input.netlify.app/"><strong><code>&lt;rich-input&gt;</code></strong></a>.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#what-is-rich-input" name="what-is-rich-input">#</a> What is <code>&lt;rich-input&gt;</code>?</h3>
<p><code>&lt;rich-input&gt;</code> is a standalone Web Component that gives you smart, keyword-aware search fields. You configure it completely declaratively by nesting standard HTML <code>&lt;datalist&gt;</code> elements inside it:</p>
<pre><code class="language-html" style="tab-size: 2">&lt;script type="module" src="https://cdn.jsdelivr.net/npm/rich-input/index.js"&gt;&lt;/script&gt;

&lt;rich-input placeholder="Search music catalog..."&gt;
  &lt;!-- String filter with rich autocomplete options --&gt;
  &lt;datalist id="label" label="Record Label"&gt;
    &lt;option value="Warp Records"&gt;
      &lt;img src="assets/warp-records.png" height="50" width="50" alt="Warp Records"&gt;
      Warp Records
    &lt;/option&gt;
    &lt;option value="Ninja Tune"&gt;
      &lt;img src="assets/ninja-tune.jpg" height="50" width="50" alt="Ninja Tune"&gt;
      Ninja Tune
    &lt;/option&gt;
  &lt;/datalist&gt;

  &lt;!-- Numeric filter --&gt;
  &lt;datalist id="year" label="Release Year" data-type="number"&gt;
    &lt;option value="2026"&gt;&lt;/option&gt;
    &lt;option value="2025"&gt;&lt;/option&gt;
    &lt;option value="2024"&gt;&lt;/option&gt;
  &lt;/datalist&gt;
&lt;/rich-input&gt;</code></pre>
<p>With just that markup in place, the component gives you:</p>
<ul>
<li><strong>Dual Autocompletion:</strong> It automatically suggests filter keywords when you start typing a word (e.g. typing <code>l</code> suggests <code>label:</code>, typing <code>y</code> suggests <code>year:</code>) as well as the values for that keyword (typing <code>label:&#8220;W</code> suggests <code>Warp Records</code>).</li>
<li><strong>Full-String Suggestion Filtering:</strong> The suggestion dropdown filters against the full token string rather than a naive slice at the caret position. Whether your caret is at <code>label:&#8220;W|arp Records&#8221;</code> or right between the colon and quotes at <code>label:|&#8220;Warp Records&#8221;</code>, the dropdown cleanly matches the actual value.</li>
<li><strong>Rich Option Markup:</strong> You can embed <code>&lt;img&gt;</code> logos or avatars inside your <code>&lt;option&gt;</code> tags. The component extracts them into the autocomplete popover and exposes <code>::part(suggestion-image)</code> so you can style them (e.g. as circular avatars).</li>
<li><strong>Validation &amp; Squiggly Underlines:</strong> If you enter an unrecognized keyword or a value that is not part of the configured <code>&lt;datalist&gt;</code>, the component marks it as invalid with a wavy red underline via <code>::highlight(rich-input-invalid)</code> once your cursor moves away or the input loses focus.</li>
<li><strong>Native Form Participation:</strong> It uses <code>formAssociated = true</code>, so it seamlessly submits via standard <code>&lt;form&gt;</code> elements and works with <code>FormData</code>.</li>
</ul>
<p>Try it out here:</p>
<p><iframe height="800" style="width: 100%;" scrolling="no" title="&lt;rich-input&gt; demo" src="https://codepen.io/editor/bramus/embed/01a0a185-7c1d-7487-9082-528055f64685/447736d063b21a1f38511101f9bf4bc9?default-tab=result" frameborder="no" loading="lazy" allowtransparency="true">See the Pen <a href="https://codepen.io/editor/bramus/pen/01a0a185-7c1d-7487-9082-528055f64685/447736d063b21a1f38511101f9bf4bc9"><code>&lt;rich-input&gt;</code> demo</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>) on <a href="https://codepen.io">CodePen</a>.</iframe></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#how-it-works-opaquerange" name="how-it-works-opaquerange">#</a> Powering it with the OpaqueRange API</h3>
<p>I knew this project would be quite easy to build thanks to the new <strong>OpaqueRange API</strong> (<a href="https://olliewilliams.xyz/blog/opaquerange/">as recently covered by Ollie Williams</a>), available in Chrome 152+.</p>
<p>Historically, doing any kind of rich interaction inside a native <code>&lt;input&gt;</code> — such as measuring exact caret coordinates to position a popover, or applying styling to substrings within the input — was a notorious exercise in pain. You had to resort to brittle &#8220;mirror div&#8221; hacks: an off-screen or invisible <code>&lt;div&gt;</code> styled with the exact same font, padding, border, and scroll offsets, replicating the input’s text to measure coordinates.</p>
<p>With <code>OpaqueRange</code> native text inputs gain first-class range capabilities. You can call <code>input.createValueRange(start, end)</code> to get an <code>OpaqueRange</code> representing any text slice inside the control. This unlocks two massive superpowers:</p>
<ol>
<li><strong>Anchoring popovers:</strong> You can call <code>range.getBoundingClientRect()</code> directly on an <code>OpaqueRange</code> to get the exact viewport coordinates of the active token or caret, allowing the autocomplete popover to anchor precisely to the start of the token.</li>
<li><strong>Custom Highlights:</strong> You can register the ranges with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API">CSS Custom Highlight API</a> via <code>CSS.highlights.set(keyword, new Highlight(range))</code>. That means you can style each keyword’s value directly in your CSS stylesheet!</li>
</ol>
<pre><code class="language-css" style="tab-size: 2">/* Style record label values */
::highlight(label) {
  background-color: oklch(0.92 0.08 240);
  color: oklch(0.28 0.14 240);
  text-decoration: 2px underline solid oklch(0.5 0.15 240 / 0.5);
}

/* Style release years */
::highlight(year) {
  background-color: oklch(0.93 0.1 85);
  color: oklch(0.35 0.14 85);
}

/* Style invalid tokens with a wavy red squiggly */
::highlight(rich-input-invalid) {
  text-decoration: underline wavy #ef4444;
  text-decoration-skip-ink: none;
}</code></pre>
<p>Because these are native highlights, text selection, copy-pasting, undo/redo stacks, and caret navigation continue to work 100% natively without any layout shifts or DOM synchronization glitches.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#cross-browser-fallback" name="cross-browser-fallback">#</a> The Cross-Browser Fallback</h3>
<p>Because <code>OpaqueRange</code> is still brand new and currently only supported in Chromium 152+, the component also comes with a built-in fallback strategy. When <code>OpaqueRange</code> is not supported, the component internally swaps the native <code>&lt;input&gt;</code> inside its Shadow DOM for an adapted, single-line <code>[contenteditable]</code> element and resorts to regular CSS Custom Highlights to do the highlighting — a technique I pioneered in <a href="https://brm.us/syntax-highlighting#highlighting-contenteditable">https://brm.us/syntax-highlighting</a>.</p



<p><mark>This broadens browser support of the component to to Chromium 105+, Safari 17.2+, and Firefox 140+.</mark></p>
<p>For some reason the Custom Highlights do not cross into the Shadow DOM in Firefox and Safari. To fix that, the component also copies over document’s custom highlights from <code>document.stylesheets</code> into the Shadow DOM. Also accepted is a <code>&lt;style&gt;</code> element nested directly inside the <code>&lt;rich-text&gt;</code>.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#built-with-antigravity" name="built-with-antigravity">#</a> AI-Assisted with Google Antigravity</h3>
<p>Just like my previous two custom element projects (<a href="https://www.bram.us/2026/09/02/html-in-canvas-pageflip/"><code>&lt;hic-pageflip&gt;</code></a> and <a href="https://www.bram.us/2026/09/10/introducing-mermaid-element-a-custom-element-to-display-mermaid-diagrams/"><code>&lt;mermaid-element&gt;</code></a>), this component was built with AI assistance using <strong>Google Antigravity</strong>.</p>
<p>I used stock Antigravity powered by Gemini, configured with <a href="https://developer.chrome.com/docs/modern-web-guidance">the Modern Web Guidance skill</a> and the <a href="https://developer.chrome.com/blog/chrome-devtools-mcp">Chrome DevTools MCP server</a>.</p>
<p>In <a href="https://github.com/bramus/rich-input/blob/main/prompt.md">my prompt</a>, also explicitly pointed Antigravity to my two previous repositories, instructing it to replicate their project layout, demo structure, and styling. Finally, I also included links to resources about <code>OpaqueRange</code>, including <a href="https://olliewilliams.xyz/blog/opaquerange/">Ollie’s post</a>. You can check <a href="https://github.com/bramus/rich-input/blob/main/prompt.md">my initial prompt</a> here:</p>
<pre><code class="language-md">Create me a rich search component `&lt;rich-search&gt;` that can automplete values for keyword-bases searches.

The component is and acts like a regular `&lt;input type=text&gt;` so you can type in just text. But when typing in specific keywords it should add suggestions for those keywords. Those keyword-searches are in the form  of `keyword:value`

As an example, some keywords and values one could entere for a music application are:
- genre:"&lt;string&gt;"
- style:"&lt;string&gt;"
- year:&lt;number&gt;
- label:"&lt;string&gt;"
- artist:"&lt;string&gt;"
- mix:"&lt;string&gt;"
- mixid:&lt;number&gt;
- playlist:"&lt;string&gt;"
- playlistid:&lt;number&gt;

An example search string could be something like `label:"We Play House Recordings" year:2026 playlist:"WPH Classics"`.

The search field should be able to autocomplete:
- The keywords. E.g. when I type in `m` at the start of a new word, it should suggest `mix:` and `mixid:` which I can choose from a list of suggested options.
- The values for those keywords. E.g. when I start typing `label:"K|` (with | being the current cursor position), then it should suggest the labels “Kranky” and “Keinemusik”.

The configuration of all this happens through `&lt;datalist&gt;` elements that are placed inside the `&lt;rich-search&gt;` element. E.g. this could be the list that suggest values for `label`:

```html
&lt;datalist id=label label="Record Label"&gt;
  &lt;option value="Defected"&gt;&lt;/option&gt;
  &lt;option value="Keinemusik"&gt;&lt;/option&gt;
  &lt;option value="Kranky"&gt;&lt;/option&gt;
  &lt;option value="Ninja Tune"&gt;&lt;/option&gt;
  &lt;option value="We Play House Recordings"&gt;&lt;/option&gt;
  &lt;option value="XL Recordings"&gt;&lt;/option&gt;
&lt;/datalist&gt;
```

Technically, implement this using the OpaqueRange API. Check these resources for info:
- https://chromestatus.com/feature/6297362687066112
- https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/OpaqueRange/explainer.md
- https://olliewilliams.xyz/blog/opaquerange/

Styling of the values using the Custom Highlights API should be possible (e.g. `::highlight(label)` can be used to style the value set in label:"We Play House Recordings"). Styling of the input itself should be done using `::part()`.

Follow the project structure as seen in the projects https://github.com/bramus/hic-pageflip and https://github.com/bramus/mermaid-element. Also use the same demo structure and style.</code></pre>
<p>Antigravity handled this prompt very well. Like, it came up with adding a little leading icon, added a “clear input” button, added keyboard navigation, added the <code>getParsedQuery()</code> API, etc. all without me asking 🙂</p>
<p><em>(Later on I <a href="https://github.com/bramus/rich-input/commit/87dcdb8157def64d4590fbbbe5a990e26a237a3e">renamed</a> it from <code>&lt;rich-search&gt;</code> to <code>&lt;rich-input&gt;</code>, added <a href="https://github.com/bramus/rich-input/commit/e34339fbf3cfc8f2b5258d4d41eed57f34e9722e">support for images</a>, added the fallbacks, etc.)</em></p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>If you take a look at the <a href="https://github.com/bramus/rich-input/commits/main/">commit history on GitHub</a>, you’ll notice that most commit messages include the exact prompt I used. One commit I really liked is <a href="https://github.com/bramus/rich-input/commit/c5011e7389b199263fb44ef5f7ced87a50229fed">the one where I asked it to generate a visual</a> similar to <a href="https://una.im/select-updates/#new-elements-and-pseudos">the one Una made for Customizable Select</a>.</p>
<p>The result <em>(after two extra nudges that got squashed into <a href="https://github.com/bramus/rich-input/commit/c5011e7389b199263fb44ef5f7ced87a50229fed">the commit</a>)</em> is this nice visual:</p>
<figure class="resizable">
  <img decoding="async" class="style-svg" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/rich-input-parts.svg" /><figcaption>The visual Antigravity generated.</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#get-the-package" name="get-the-package">#</a> Get the package</h3>
<p>You can use <code>&lt;rich-input&gt;</code> right now or drop it into your own projects:</p>
<p>To install it via npm:</p>
<pre class="language-bash"><code class="language-bash" style="tab-size: 2">npm install rich-input</code></pre>
<p>Once imported, the custom element is ready to use (the package registers the component all by itself)</p>
<pre class="language-js"><code class="language-js" style="tab-size: 2">import rich-input;</code></pre>
<p>Alternatively, you can load it directly in HTML from a CDN such as jsDelivr:</p>
<pre class="language-html"><code class="language-html" style="tab-size: 2">&lt;script type="module" src="https://cdn.jsdelivr.net/npm/rich-input/index.js"&gt;&lt;/script&gt;</code></pre>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#links-and-resources" name="links-and-resources">#</a> Links and Resources</h3>
<p>Check out the following links for more info and demos:</p>
<ul>
<li>🚀 <strong>Project Homepage &amp; Live Demo:</strong> <a href="https://rich-input.netlify.app/">https://rich-input.netlify.app/ &rarr;</a></li>
<li>📦 <strong>NPM Package:</strong> <a href="https://www.npmjs.com/package/rich-input"><code>rich-input</code> on npm &rarr;</a></li>
<li>💻 <strong>Source Code:</strong> <a href="https://github.com/bramus/rich-input"><code>bramus/rich-input</code> on GitHub &rarr;</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#spread-the-word" name="spread-the-word">#</a> Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/…">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117269454186489042">🦣 Mastodon</a></li>
<li><a href="https://lnkd.in/p/eAb2xezC">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/14/introducing-rich-input-a-github-like-search-filter-text-input-to-embed-on-your-site/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>WebKit supports interactive-widget … and hopefully Safari will too?</title>
		<link>https://www.bram.us/2026/09/11/webkit-supports-interactive-widget-and-hopefully-safari-will-too/</link>
					<comments>https://www.bram.us/2026/09/11/webkit-supports-interactive-widget-and-hopefully-safari-will-too/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Fri, 11 Sep 2026 21:55:05 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[metatags]]></category>
		<category><![CDATA[viewport]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36717</guid>

					<description><![CDATA[<p>About a month ago, WebKit gained support for <code>interactive-widget</code>. While it’s not clear whether this will end up in Safari or not, this is pretty exciting news: If you have ever had a virtual keyboard obscure your fixed navigation bar, then <code>interactive-widget</code> is for you.</p><p>This post details a quick recap of what the feature does, how we got here, and a sneak peek at it running in WebKit.</p>]]></description>
										<content:encoded><![CDATA[<div class="intro">
<p>About a month ago, WebKit gained support for <code>interactive-widget</code>. While it’s not clear whether this will end up in Safari or not, this is pretty exciting news: If you have ever had a virtual keyboard obscure your fixed navigation bar, then <code>interactive-widget</code> is for you.</p>
<p>This post details a quick recap of what the feature does, how we got here, and a sneak peek at it running in WebKit.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#the-backstory-interop-2022" name="the-backstory-interop-2022">#</a> The backstory: Interop 2022</h3>
<p>Back in 2022, I did a lot of research into viewports as the lead of the <a href="https://github.com/web-platform-tests/interop-2022-viewport">Viewport Investigation Effort</a>, part of Interop 2022.</p>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/viewport-investigation-effort-testpages.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/viewport-investigation-effort-testpages.png" alt="" width="560" height="374" class="alignnone size-medium wp-image-36727" /></a><figcaption>Screenshot of <a href="https://interop-2022-viewport.netlify.app/">the Viewport Investigation Effort Demo Pages</a></figcaption></figure>
<p>While that effort still hasn’t led to an actual Interop Proposal <em>(due to the lack of proper mobile testing, something that is <a href="https://github.com/web-platform-tests/interop-mobile-testing">in the works</a> ever since 2023 😏)</em>, one of the outcomes was that we at Chrome <a href="https://developer.chrome.com/blog/viewport-resize-behavior#changing_the_default_behavior_in_chrome_108">changed how viewports get resized</a> when <a href="https://github.com/web-platform-tests/interop-2022-viewport/blob/main/explainers/virtual-keyboard.md">the virtual keyboard</a> gets shown in Chrome on Android.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#enter-interactive-widget" name="enter-interactive-widget">#</a> Enter <code>interactive-widget</code></h3>
<p>Where Chrome used to resize the <a href="https://github.com/web-platform-tests/interop-2022-viewport/blob/main/explainers/layout-viewport.md">layout viewport</a> by default, its behavior changed in Chrome 108. As of that version, which shipped in 2022, Chrome only resizes <a href="https://github.com/web-platform-tests/interop-2022-viewport/blob/main/explainers/visual-viewport.md">the visual viewport</a>, thereby aligning its behavior with that of Safari.</p>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-chrome-old-and-new.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-chrome-old-and-new.png" alt="" width="560" height="315" class="alignnone size-medium wp-image-36730" /></a><figcaption>Chrome’s old and new resize behavior. In the new behavior, which aligns with what Safari does, Chrome only resizes the visual viewport (orange dotted outline) when the virtual keyboard gets shown. The Layout Viewport (blue dashed outline) remains untouched. This shipped in 2022.</figcaption></figure>
<p>The side-effect of this change, though, is that you could end up with <code>position: fixed</code> content that gets obscured by the virtual keyboard:</p>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-chrome-old-and-new-with-fixed-content.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-chrome-old-and-new-with-fixed-content.png" alt="" width="560" height="315" class="alignnone size-medium wp-image-36731" /></a><figcaption>Chrome’s old and new resize behavior, with <code>position: fixed</code> content. In the new behavior, that content can get obscured by the virtual keyboard.</figcaption></figure>
<p>As some developers heavily relied on the old behavior — or just want to have a fixed bottom toolbar that sits atop the virtual keyboard – we at Chrome <a href="https://github.com/bramus/viewport-resize-behavior/blob/main/explainer.md">developed</a> a way to control what should happen when the virtual keyboard pops up: the <code>interactive-widget</code> directive for use in <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport">the viewport <code>&lt;meta&gt;</code> tag</a>.</p>
<pre><code class="language-html" style="tab-size: 2">&lt;meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"&gt;</code></pre>
<p>The directive accepts three values:</p>
<ul>
<li><code>resizes-visual</code>: Resize only the Visual Viewport but not the Layout Viewport <em>(default behavior)</em>.</li>
<li><code>resizes-content</code>: Resize both the Visual Viewport and Layout Viewport.</li>
<li><code>overlays-content</code>: Do not resize any viewport. This is similar to using the <a href="http://brm.us/virtual-keyboard">Virtual Keyboard API</a> with <code>overlaysContent</code> set to <code>true</code>.</li>
</ul>
<p>Put visually:</p>
<figure>
<a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-side-by-side.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/resize-behavior-side-by-side.png" alt="" width="560" height="315" class="alignnone size-medium wp-image-36732" /></a><figcaption>The impact of the various values for <code>interactive-widget</code>. The orange dotted box is the Visual Viewport. The blue one is the Layout Viewport. Both resize differently depending on the value of <code>interactive-widget</code>.</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#available-in-webkit" name="available-in-webkit">#</a> Available in WebKit</h3>
<p>About a month ago (mid-August), I was surprised to read a comment on the WebKit GitHub repo that <a href="https://github.com/WebKit/standards-positions/issues/65#issuecomment-5346189958"><code>interactive-widget</code> had been implemented in WebKit</a>.</p>
<p>Eager to test this out, it quickly became clear that this was not exactly possible without <a href="https://github.com/WebKit/WebKit/#building-for-apple-platforms">building WebKit yourself</a> and running it using a simple browser shell called <code>MobileMiniBrowser</code> on the iPhone Simulator. A pity, as I don’t think all web developers out there can find their way around that.</p>
<p>Anywho, today I bit the bullet and built WebKit locally. Some time later (with fan noises as background music throughout) I had <code>MobileMiniBrowser</code> open in an iPhone Simulator with <a href="https://viewport-resize-behavior.netlify.app/">the Viewport Resize Behavior demos</a> open.</p>
<div class="table">
<div>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual.png" alt="" width="293" height="560" class="alignnone size-medium wp-image-36723" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual.png 852w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual-293x560.png 293w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual-585x1120.png 585w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual-768x1469.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-visual-803x1536.png 803w" sizes="auto, (max-width: 293px) 100vw, 293px" /></a><figcaption>MobileMiniBrowser showing a test page set to <code>resizes-visual</code></figcaption></figure>
</div>
<div>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content.png" alt="" width="293" height="560" class="alignnone size-medium wp-image-36724" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content.png 852w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content-293x560.png 293w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content-585x1120.png 585w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content-768x1469.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-resizes-content-803x1536.png 803w" sizes="auto, (max-width: 293px) 100vw, 293px" /></a><figcaption>MobileMiniBrowser showing a test page set to <code>resizes-content</code></figcaption></figure>
</div>
<div>
<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content.png" alt="" width="293" height="560" class="alignnone size-medium wp-image-36725" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content.png 852w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content-293x560.png 293w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content-585x1120.png 585w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content-768x1469.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/MobileMiniBrowser-overlays-content-803x1536.png 803w" sizes="auto, (max-width: 293px) 100vw, 293px" /></a><figcaption>MobileMiniBrowser showing a test page set to <code>overlays-content</code></figcaption></figure>
</div>
</div>
<p>While <code>interactive-widget</code> works fine in <code>MobileMiniBrowser</code> <em>(hooray!)</em>, I still would like to test things in a <em>real</em> Safari on iOS. For that I will probably have to wait for Safari 27.1 to come, which, I guess, will be the version that will include this change. It is not clear though, because <a href="https://developer.apple.com/documentation/safari-technology-preview-release-notes">the Safari Technology Preview release notes</a> have no mention of <code>interactive-widget</code>, even though Safari on desktop shows it in the list of feature flags …</p>
<figure>
<a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget.png" alt="" width="560" height="423" class="alignnone size-medium wp-image-36737" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget.png 1896w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget-560x423.png 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget-1120x846.png 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget-768x580.png 768w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget-1536x1160.png 1536w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/safari-stp-feature-flag-interactive-widget-1568x1184.png 1568w" sizes="auto, (max-width: 560px) 100vw, 560px" /></a><figcaption>The feature flag in Safari Technology Preview 252 on macOS</figcaption></figure>
<p>Reason I would like to test Safari on iOS itself, is because its UI differs a lot from the <code>MobileMiniBrowser</code>. For example, I’d like to see if and how Safari’s floating address bar influences things here. It’s probably fine already — there’s clever people work on Safari after all — but ever since <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport#viewport-fit"><code>viewport-fit</code></a> ended up <a href="https://bsky.app/profile/bram.us/post/3lyxisc4mt22s">getting broken in Safari 26</a> <em>(and still is broken to this day)</em>, I’d like to double check.</p>
<p><em>(💭 Side thought: If only there were a Safari Technology Preview for iOS …)</em></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#browser-support" name="browser-support">#</a> Browser Support</h3>
<div class="note note--supergreen">
<p>💡 Although this post was originally published in September 2026, the list below is constantly being updated. <em>Last update: September 11, 2026</em>.</p>
</div>
<p><code>interactive-widget</code> is supported in the following browsers:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>✅ Supported in Chrome 108 (Android)</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>✅ Supported in Firefox 133 (Android)</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>⏳ In development. Implemented in the WebKit source, but not yet shipped in a public release of Safari or Safari Technology Preview.</p>
</dd>
</dl>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#closing-thoughts" name="closing-thoughts">#</a> Closing thoughts</h3>
<p>I’m excited to see <code>interactive-widget</code>, a feature I helped shape, become part of WebKit. Hopefully it will come to Safari as well, so that developers can get control over how the viewport should resize when the virtual keyboard gets shown. To learn more about <code>interactive-widget</code>, check out the following resources I’ve authored:</p>
<ul>
<li><a href="https://www.htmhell.dev/adventcalendar/2024/4/">Control the Viewport Resize Behavior on mobile with <code>interactive-widget</code> &rarr;</a></li>
<li><a href="https://github.com/bramus/viewport-resize-behavior/blob/main/explainer.md">Viewport vs Virtual Keyboard Resize Behavior Explainer &rarr;</a></li>
<li><a href="https://developer.chrome.com/blog/viewport-resize-behavior">Prepare for viewport resize behavior changes coming to Chrome on Android &rarr;</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/11/webkit-supports-interactive-widget-and-hopefully-safari-will-too/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Introducing &#x3C;&#x200B;mermaid-element&#x3E;, a custom element to display Mermaid diagrams</title>
		<link>https://www.bram.us/2026/09/10/introducing-mermaid-element-a-custom-element-to-display-mermaid-diagrams/</link>
					<comments>https://www.bram.us/2026/09/10/introducing-mermaid-element-a-custom-element-to-display-mermaid-diagrams/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 12:48:19 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[custom elements]]></category>
		<category><![CDATA[diagram]]></category>
		<category><![CDATA[mermaid]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36704</guid>

					<description><![CDATA[<p>Today I learned that <a href="https://codepen.io/">CodePen</a> supports <a href="https://mermaid.js.org/">Mermaid diagrams</a> — <a href="https://chriscoyier.net/">Chris Coyier</a> has a <a href="https://codepen.io/collection/xepBVz">a whole collection with examples</a> here — and I also learned that there is no custom element to easily include these in your markup … so I built one.</p>]]></description>
										<content:encoded><![CDATA[<figure><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/mermaid-element.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/mermaid-element.png" alt="" width="560" height="239" class="alignnone size-medium wp-image-36707" /></a></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="intro">
<p>Today I learned that <a href="https://codepen.io/">CodePen</a> supports <a href="https://mermaid.js.org/">Mermaid diagrams</a> — <a href="https://chriscoyier.net/">Chris Coyier</a> has a <a href="https://codepen.io/collection/xepBVz">a whole collection with examples</a> here — and I also learned that there is no custom element to easily include these in your markup … so I built one.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>Looking at Chris’s demos, I see CodePen relies on Markdown code blocks with backticks (<code>```mermaid</code>) to render the diagrams. Seeing that in action got me wondering: is there a standalone custom element out there to drop Mermaid diagrams declaratively onto any web page? Turns out, there wasn’t.</p>
<p>So I built one, assisted by Antigravity: <a href="https://mermaid-element.netlify.app/"><code>&lt;mermaid-element&gt;</code></strong></a>.</p>
<p>It’s a zero-config, dependency-free web component that renders Mermaid diagrams inside an open Shadow DOM. Simply import the script and put your Mermaid syntax directly inside the custom element:</p>
<pre><code class="language-html" style="tab-size: 2">&lt;script type="module" src="https://cdn.jsdelivr.net/npm/mermaid-element/index.js"&gt;&lt;/script&gt;
&lt;mermaid-element&gt;
  graph TD
    Client[Client Request] --&gt; LB[Load Balancer]
    LB --&gt; Server1[Server 01]
    LB --&gt; Server2[Server 02]
&lt;/mermaid-element&gt;</code></pre>
<p><em>(Yes, you can also get the component <a href="https://www.npmjs.com/package/mermaid-element">from npm</a>)</em></p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>By default, <code>&lt;mermaid-element&gt;</code> is a drop in component that will automatically load Mermaid <em>(version 12)</em> on demand via jsDelivr. However, if you want to use a different version of Mermaid, you can pin a specific version or supply a custom endpoint using the <code>mermaid</code> attribute. Just set <code>mermaid</code> to a version string <em>(e.g. <code>mermaid="10.9.8"</code>)</em> or a full URL, and <code>&lt;mermaid-element&gt;</code> will use that.</p>
<p>The component also comes with a built-in in-memory cache. If you have multiple diagrams on the same page, they will download Mermaid only once. Also, if your app already bundles Mermaid, you can assign your own copy to <code>MermaidElement.defaultMermaid</code> to bypass external CDN requests altogether.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>I also built an interactive playground to show some examples and to show how you can configure the component. The resulting HTML is shown right underneath the visualization, ready for you to copy and paste:</p>
<p class="codepen" data-height="1020" data-pen-title="&amp;lt;mermaid-element&amp;gt; playground" data-version="2" data-default-tab="result" data-slug-hash="QwpEOPj" data-user="bramus" style="height: 1020px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/editor/bramus/pen/01a08b11-6e3d-7f5f-afff-15b5a868ea35"><br />
  &lt;mermaid-element&gt; playground</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br />
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<p><script async src="https://public.codepenassets.com/embed/index.js"></script></p>
<p>The <a href="https://mermaid-element.netlify.app/"><code>&lt;mermaid-element&gt;</code></strong> website</a> has some more examples (and instructions).</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>Check out the live documentation or grab the package from npm:</p>
<ul>
<li><a href="https://mermaid-element.netlify.app/"><code>&lt;mermaid-element&gt;</code> Website &amp; Live Demo &rarr;</a></li>
<li><a href="https://www.npmjs.com/package/mermaid-element"><code>mermaid-element</code> on npm &rarr;</a></li>
<li><a href="https://github.com/bramus/mermaid-element"><code>mermaid-element</code> on GitHub &rarr;</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/10/introducing-mermaid-element-a-custom-element-to-display-mermaid-diagrams/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Animating CSS Grid Layouts with CSS Anchor Positioning</title>
		<link>https://www.bram.us/2026/09/07/animating-css-grid-layouts-with-css-anchor-positioning/</link>
					<comments>https://www.bram.us/2026/09/07/animating-css-grid-layouts-with-css-anchor-positioning/#comments</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Mon, 07 Sep 2026 21:27:48 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36513</guid>

					<description><![CDATA[<p>At CSS Day (back in June), someone asked me how Brave (the browser) might have built their New Tab Page which features some tiles laid out in a grid. While the grid is nothing spectacular, something special happens with it when you resize the viewport: as the available space changes, the grid nicely reorganizes itself in an animated way. The animations are also interruptible, so no View Transitions seem to be at play.</p><p>So, how did they do it? I’m not sure. But if I were to recreate it, I’d use CSS Anchor Positioning with regular CSS Transitions on top.</p>]]></description>
										<content:encoded><![CDATA[<figure>
  <div style="width: 640px;" class="wp-video"><video class="wp-video-shortcode" id="video-36513-2" width="640" height="427" loop autoplay muted preload="metadata" controls="controls"><source type="video/mp4" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid-2.mp4?_=2" /><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid-2.mp4">https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid-2.mp4</a></video></div><figcaption>Recording of <a href="https://codepen.io/editor/bramus/pen/01a07dd4-4d23-7e7b-9f41-3e0aa126751f">the high-fidelity demo</a>.</figcaption></figure>
<div class="intro">
<p>At <a href="https://cssday.nl/">CSS Day</a> (back in June), someone asked me how Brave (the browser) might have built their New Tab Page which features some tiles laid out in a grid. While the grid in itself is nothing spectacular, something special happens with it when you resize the viewport: as the available space changes, the grid nicely reorganizes itself in an animated way. The animations are also interruptible, so no View Transitions seem to be at play.</p>
<p>So, how did they do it? I’m not sure. But if I were to recreate it, I’d use CSS Anchor Positioning with regular CSS Transitions on top.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
<p>💁‍♂️ Not familiar with Anchor Positioning yet? Check out my post <a href="https://www.bram.us/2026/02/28/anchors-aweigh-sotb2026/">Anchors Aweigh!</a> which explains all you need to know about it <em>(video included!)</em>.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#the-technique" name="the-technique">#</a> The Technique</h3>
<p>The effect we are after is this one: as the grid resizes, the grid cells nicely animate to their new position:</p>
<figure>
  <div style="width: 640px;" class="wp-video"><video class="wp-video-shortcode" id="video-36513-3" width="640" height="268" loop autoplay muted preload="metadata" controls="controls"><source type="video/mp4" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid.mp4?_=3" /><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid.mp4">https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid.mp4</a></video></div><figcaption>Recording of <a href="https://codepen.io/bramus/pen/ogBYjgm">the animated grid demo</a>.</figcaption></figure>
<p>The trick here is to use <strong>CSS Anchor Positioning</strong> and anchor the cell’s content to the cell itself. Since you can’t anchor content directly onto a grid’s cell — grid cells are virtual concepts — you need to inject an extra wrapper div that is set to take up the cell’s available space and act as the anchor. Regular CSS transitions are then responsible for nicely animating things as they shift around.</p>
<pre><code class="language-html" style="tab-size: 2">&lt;div class="grid"&gt;
	&lt;div class="cell"&gt;
		&lt;div class="content"&gt;CONTENT&lt;/div&gt;
	&lt;/div&gt;
	&lt;div class="cell"&gt;
		&lt;div class="content"&gt;CONTENT&lt;/div&gt;
	&lt;/div&gt;
	…
&lt;/div&gt;</code></pre>
<pre><code class="language-css" style="tab-size: 2">.grid {
	display: grid;
	grid-template-columns: repeat(auto-fill, 6rem);
	gap: 1em;
}

.cell {
	height: auto;
	aspect-ratio: 1;
	
	anchor-scope: --a;
	anchor-name: --a;
}

.content {
	position: absolute;
	position-anchor: --a;
	inset: anchor(inside);
	width: 6rem;
	height: 6rem;

	transition: inset 0.2s ease;
}</code></pre>
<p>There are four key details that make this whole setup click:</p>
<ol>
<li>
<p>Because anchor names are by default global, they need to be scoped to a specific cell’s subtree. <code>anchor-scope: --a;</code> takes care of that, allowing every <code>.cell</code> to reuse the <code>--a</code> identifier without clashing with any other cells.</p>
</li>
<li>
<p>The <code>aspect-ratio: 1;</code> on the <code>.cell</code>s ensures they take up as much height as they are wide.</p>
</li>
<li>
<p>The <code>transition</code> declaration ensures the <code>inset</code> nicely transitions from one value to the other.</p>
</li>
<li>
<p>The <code>.content</code> divs are given explicit dimensions (<code>width</code> and <code>height</code>). Without it, the <code>.content</code> divs would stretch and squeeze as they interpolate across the resizing grid.</p>
</li>
</ol>
<p>Unlike with View Transitions, this technique requires absolutely no JavaScript and the animations are perfectly interruptible. If you rapidly resize the viewport back and forth, the tiles don’t get queued up waiting for animation frames or transition promises to finish—they immediately redirect mid-flight toward their newly calculated positions.</p>
<div class="note">
<p>Note: This currently does not work in Firefox because it does not do <a href="https://drafts.csswg.org/css-anchor-position-1/#interleaving">style and layout interleaving</a> for Anchor Positioning. Instead of resolving to interpolable pixel values, it retains the <code>anchor()</code> value at <a href="http://brm.us/value-processing">computed value time</a>.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#demo" name="demo">#</a> Demo</h3>
<p>Here is <a href="https://codepen.io/bramus/pen/ogBYjgm">the demo</a> I built to demonstrate this behavior:</p>
<p class="codepen" data-height="700" data-pen-title="Animated Reorganizing Grid with Anchor Positioning (No View Transitions!)" data-default-tab="result" data-slug-hash="ogBYjgm" data-user="bramus" style="height: 700px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/bramus/pen/ogBYjgm"><br />
      Animated Reorganizing Grid with Anchor Positioning (No View Transitions!)</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br />
    on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<p><script async src="https://public.codepenassets.com/embed/index.js"></script></p>
<p>Try resizing the embed or the <code>.container</code> in the demo itself to see the grid rearrange itself.</p>
<p>Or, if you want something more high-fidelity (and that more closely matches the Brave New Tab Page IIRC):</p>
<p class="codepen" data-height="650" data-pen-title="Animated CSS Grid with CSS Anchor Positioning" data-version="2" data-default-tab="result" data-slug-hash="zxZqpqa" data-user="bramus" style="height: 650px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/editor/bramus/pen/01a07dd4-4d23-7e7b-9f41-3e0aa126751f"><br />
  Animated CSS Grid with CSS Anchor Positioning</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br />
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#spread-the-word" name="spread-the-word">#</a> Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3muxheuln3224">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117231888486742468">🦣 Mastodon</a></li>
<li><a href="https://lnkd.in/p/ez-NhfSa">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
<p>If this post felt familiar:</p>
<ul>
<li>I actually <a href="https://bsky.app/profile/bram.us/post/3moe5buoab22w">shared a demo on Bluesky</a> about this back in June. I only got to writing this blog post today. Oh well.</p>
</li>
<li><a href="https://chriscoyier.net/">Chris Coyier</a> covered the approach of my demo on Frontend Masters. In his write-up, he looks back at how Masonry.js famously animated fluid layouts, and shows how my code brings that exact behavior natively to CSS. Definitely go check out his article: <a href="https://frontendmasters.com/blog/masonry-with-animation-in-css/">Masonry (with Animation) in CSS</a>.</li>
</ul>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/07/animating-css-grid-layouts-with-css-anchor-positioning/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		<enclosure url="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid.mp4" length="5531795" type="video/mp4" />
<enclosure url="https://www.bram.us/wordpress/wp-content/uploads/2026/09/animated-css-grid-2.mp4" length="557058" type="video/mp4" />

			</item>
		<item>
		<title>Introducing &#x3C;&#x200B;hic-pageflip&#x3E;: A Pageflip/Flipbook Custom Element powered by HTML-in-Canvas</title>
		<link>https://www.bram.us/2026/09/02/html-in-canvas-pageflip/</link>
					<comments>https://www.bram.us/2026/09/02/html-in-canvas-pageflip/#respond</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Wed, 02 Sep 2026 20:53:39 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[html-in-canvas]]></category>
		<category><![CDATA[pageflip]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36636</guid>

					<description><![CDATA[<p>Way back in 2005, I used a Flash PageFlip component to display a book on a website. Ever since, recreating a modern version has been on my backlog to investigate. With the arrival of the experimental HTML-in-Canvas API, we can finally bring it back — this time displaying real HTML.</p>]]></description>
										<content:encoded><![CDATA[<figure><div style="width: 640px;" class="wp-video"><video class="wp-video-shortcode" id="video-36636-4" width="640" height="278" loop autoplay muted preload="metadata" controls="controls"><source type="video/mp4" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/hic-pageflip.mp4?_=4" /><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/hic-pageflip.mp4">https://www.bram.us/wordpress/wp-content/uploads/2026/09/hic-pageflip.mp4</a></video></div><figcaption>Recording of <a href="https://hic-pageflip.netlify.app/">the demo</a>.</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="intro">
<p>Way back in 2005, I used a Flash PageFlip component to display a book on a website. Ever since, recreating a modern version has been on my backlog to investigate. With the arrival of the experimental HTML-in-Canvas API, we can finally bring it back — this time displaying real HTML.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note note--warning">
<p><strong>⚠️👨‍🔬 Experimental Feature!</strong></p>
<p>The technology used to power this <code>&lt;hic-pageflip&gt;</code> component is still experimental and is undergoing significant changes. To see the component in action, you need Chrome with <code>chrome://flags#canvas-draw-element</code> enabled.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>PageFlip?</h3>
<p><strong>PageFlip</strong> is a classic UI pattern that simulates a hyper-realistic, interactive book or magazine: users can click, drag, and peel page corners to flip through content with dynamic lighting, curls, and shadows. It was popularized in 2005–2006 by Iparigrafika who shipped a Flash Component.</p>
<figure>
<a href="https://www.bram.us/wordpress/wp-content/uploads/2026/09/orig-pageflip.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/orig-pageflip.png" alt="" width="560" height="381" class="alignnone size-medium wp-image-36649" /></a><figcaption>Screenshot of the (now defunct) PageFlip website <em>(<a href="https://web.archive.org/web/20060307042409/http://www.iparigrafika.hu/pageflip/">archived</a>)</em></figcaption></figure>
<p>While recreating this effect on the web has technically been possible for years using 2D Canvas or WebGL shaders, it always came with massive accessibility and usability drawbacks. Because the pages were trapped inside a canvas pixel grid or rendered as static images, you lost selectable text, clickable links, screen reader support, and browser features like find-in-page.</p>
<p>Using HTML to render pages has also been possible by leveraging CSS transforms <em>(see <a href="https://online.fliphtml5.com/vnefl/pwjz/">this example</a>)</em>. However, that effect was always somewhat “fake”: it’s a pure 2D clip effect with a shadow laid on top, instead of true 3D folding and curling as described in <a href="https://blog.flirble.org/2010/10/08/the-anatomy-of-a-page-curl/">Chris Luke’s “The anatomy of a page curl”</a>.</p>
<p><script src="https://unpkg.com/@cloudfour/image-compare/dist/index.min.js"></script></p>
<figure>
<image-compare label-text=""><br />
  <img loading="lazy" decoding="async" slot="image-1" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-2d.jpg" alt="The 2D fold effect" width="560" height="505" class="size-medium wp-image-36653" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-2d.jpg 1276w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-2d-560x505.jpg 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-2d-1120x1010.jpg 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-2d-768x693.jpg 768w" sizes="auto, (max-width: 560px) 100vw, 560px" /><br />
  <img loading="lazy" decoding="async" slot="image-2" src="https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-3d.jpg" alt="The 3D fold effect" width="560" height="505" class="size-medium wp-image-36651" srcset="https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-3d.jpg 1276w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-3d-560x505.jpg 560w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-3d-1120x1010.jpg 1120w, https://www.bram.us/wordpress/wp-content/uploads/2026/09/pageflip-3d-768x693.jpg 768w" sizes="auto, (max-width: 560px) 100vw, 560px" /><br />
</image-compare><figcaption>Comparison of a 2D vs 3D fold effect. Look closely at the shadow and the curvature of the page, and how it affects the text. Use the slider to reveal either the 2D or 3D effect screenshot.</figcaption></figure>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>HTML-in-Canvas was made for this</h3>
<p>Up until now, you always had to compromise: either get authentic 3D page curls with Canvas or WebGL at the cost of throwing away accessibility, or stick to real HTML with CSS transforms and settle for a flat 2D clipping illusion.</p>
<p>The new (and still experimental) <a href="https://developer.chrome.com/blog/html-in-canvas-origin-trial">HTML-in-Canvas API</a> changes the game. By letting you draw live DOM elements directly into canvas contexts and WebGL textures, you no longer have to choose. You can build true 3D conical and cylindrical page curls, while the pages remain fully interactive and accessible, with selectable text and clickable links.</p>
<p>Armed with Google Antigravity and the math from <a href="https://blog.flirble.org/2010/10/08/the-anatomy-of-a-page-curl/">Chris Luke&#8217;s article</a>, I set out to build it.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Keeping Things Easy: <code>&lt;hic-pageflip&gt;</code></h3>
<p>In under two hours, I had a working pageflip prototype powered by HTML-in-Canvas up and running. I then continued the work to further refine the interactions and visuals, and eventually had Antigravity refactor the whole thing into an easy-to-use web component: <strong><code>&lt;hic-pageflip&gt;</code></strong> </p>
<p>You can see it in action on the <a href="https://hic-pageflip.netlify.app/">demo website</a> which is also embedded here:</p>
<p><iframe src="https://hic-pageflip.netlify.app/" height="600"></iframe></p>
<p>The source code is <a href="https://github.com/bramus/hic-pageflip">available on GitHub</a> and you can <a href="https://www.npmjs.com/package/hic-pageflip">install the package from NPM</a>.</p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>Installation</h4>
<p>You can install the package <a href="https://www.npmjs.com/package/hic-pageflip">from npm</a>:</p>
<pre class="language-bash"><code class="language-bash">npm install hic-pageflip</code></pre>
<p>Import the package in your application to have it register the custom elements:</p>
<pre class="language-javascript"><code class="language-javascript">import 'hic-pageflip';</code></pre>
<p>You can also load it directly from a CDN and use it in your HTML, without npm at all. For example, using jsDelivr:</p>
<pre class="language-html"><code class="language-html">&#x3C;script type=&#x22;module&#x22; src=&#x22;https://cdn.jsdelivr.net/npm/hic-pageflip&#x22;&#x3E;&#x3C;/script&#x3E;</code></pre>
<p>Once installed and imported, you can start using it.</p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>Minimal Example</h4>
<p>With the component, creating a pageflip interface powered by HTML-in-Canvas becomes as easy as this:</p>
<pre class="language-html"><code class="language-html">&lt;hic-pageflip engine=&quot;3d&quot; page-width=&quot;300&quot; page-height=&quot;450&quot; page-background=&quot;#c1c8c7&quot;&gt;
&#x9;&lt;!-- Slide 1: Cover --&gt;
&#x9;&lt;hic-pageflip-page&gt;
&#x9;&#x9;&lt;div class=&quot;content&quot;&gt;
&#x9;&#x9;&#x9;&lt;h1&gt;Cover Page&lt;/h1&gt;
&#x9;&#x9;&#x9;&lt;p&gt;This is HTML inside a 3D WebGL pageflip!&lt;/p&gt;
&#x9;&#x9;&lt;/div&gt;
&#x9;&lt;/hic-pageflip-page&gt;

&#x9;&lt;!-- Slide 2 --&gt;
&#x9;&lt;hic-pageflip-page&gt;
&#x9;&#x9;&lt;div class=&quot;content&quot;&gt;
&#x9;&#x9;&#x9;&lt;h2&gt;Inside Left Page&lt;/h2&gt;
&#x9;&#x9;&#x9;&lt;p&gt;Selectable text and clickable &lt;a href=&quot;https://github.com/bramus/hic-pageflip&quot; target=&quot;_top&quot;&gt;links&lt;/a&gt; work natively.&lt;/p&gt;
&#x9;&#x9;&lt;/div&gt;
&#x9;&lt;/hic-pageflip-page&gt;

&#x9;&lt;!-- Slide 3 --&gt;
&#x9;&lt;hic-pageflip-page&gt;
&#x9;&#x9;&lt;div class=&quot;content&quot;&gt;
&#x9;&#x9;&#x9;&lt;h2&gt;Inside Right Page&lt;/h2&gt;
&#x9;&#x9;&#x9;&lt;p&gt;And images (with the right CORS headers)!&lt;/p&gt;
&#x9;&#x9;&#x9;&lt;p&gt;&lt;img src=&quot;https://gravatar.com/avatar/c47bf5e271115acb3820392b0a5c9574&quot; alt=&quot;Bramus&quot; height=&quot;50&quot; width=&quot;50&quot; crossorigin=&quot;anonymous&quot;&gt;&lt;/p&gt;
&#x9;&#x9;&lt;/div&gt;
&#x9;&lt;/hic-pageflip-page&gt;

&#x9;&lt;!-- Slide 4: Backcover --&gt;
&#x9;&lt;hic-pageflip-page&gt;
&#x9;&#x9;&lt;div class=&quot;content&quot;&gt;
&#x9;&#x9;&#x9;&lt;h2&gt;Backcover&lt;/h2&gt;
&#x9;&#x9;&#x9;&lt;p&gt;And CSS as well &mldr;&lt;/p&gt;
&#x9;&#x9;&#x9;&lt;p class=&quot;rainbow&quot;&gt;Rainbow text, Yay!&lt;/p&gt;
&#x9;&#x9;&lt;/div&gt;
&#x9;&lt;/hic-pageflip-page&gt;
&lt;/hic-pageflip&gt;</code></pre>
<p>The enclosing <code>&lt;hic-pageflip&gt;</code> component creates the pageflip interface, and the <code>&lt;hic-pageflip-page&gt;</code> components are the pages contained within.</p>
<p>With some mininal styling applied, the code above looks like this:</p>
<p><iframe height="600" style="width: 100%;" scrolling="no" title="&lt;hic-pageflip&gt; demo" src="https://codepen.io/editor/bramus/embed/01a05ee7-e4cb-703d-b441-e9316147d7d0?default-tab=result" frameborder="no" loading="lazy" allowtransparency="true"><br />
  See the Pen <a href="https://codepen.io/editor/bramus/pen/01a05ee7-e4cb-703d-b441-e9316147d7d0">&lt;hic-pageflip&gt; demo</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>) on <a href="https://codepen.io">CodePen</a>.<br />
</iframe></p>
<p style="text-align: center; font-size: small; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>Customization</h4>
<p>Use the attributes on the <code>&lt;hic-pageflip&gt;</code> component to control a bunch of things, including which engine to use:</p>
<ol>
<li><strong>2D Engine (<code>engine="2d"</code>)</strong>: Uses 2D canvas affine matrix reflections, clipping, and dynamic drop shadows.</li>
<li><strong>3D Engine (<code>engine="3d"</code>)</strong>: Uses WebGL vertex shaders for true cylindrical and conical page curls, based on <a href="https://blog.flirble.org/2010/10/08/the-anatomy-of-a-page-curl/">Chris Luke’s page curl algorithm</a>.</li>
</ol>
<p>The default is <code>3d</code>, the one with the true page curl effect.</p>
<p>The <code>&lt;hic-pageflip&gt;</code> component itself only offers the pageflip interface, without any controls. However, you can control the pageflip programmatically by calling methods directly on the component:</p>
<pre class="language-javascript"><code class="language-javascript">const pageflip = document.querySelector('hic-pageflip');
// Go to next page
pageflip.next();
// Go to previous page
pageflip.previous();
// Go to page 4
pageflip.goToPage(4);
// Flip to a page
pageflip.flipTo(4);</code></pre>
<p>Hook these up to your own buttons and you’re good to go 🙂</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Links and resources</h3>
<p>To recap, here are all the links you need:</p>
<ul>
<li>🚀 <strong>Project Homepage + Demo</strong>: <a href="https://hic-pageflip.netlify.app/">https://hic-pageflip.netlify.app/ &rarr;</a></li>
<li>🤖 <strong>Minimal Demo</strong>: <a href="https://codepen.io/editor/bramus/pen/01a05ee7-e4cb-703d-b441-e9316147d7d0"><code>&lt;hic-pageflip&gt;</code> demo (CodePen) &rarr;</a></li>
<li>💻 <strong>Source Code</strong>: <a href="https://github.com/bramus/hic-pageflip"><code>bramus/hic-pageflip</code> &rarr;</a></li>
<li>📦 <strong>NPM Package</strong>: <a href="https://npmjs.com/package/hic-pageflip"><code>hic-pageflip</code> &rarr;</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3muktnmp3zk2i">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117203444201725394">🦣 Mastodon</a></li>
<li><a href="https://lnkd.in/p/eyps4dAa">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/09/02/html-in-canvas-pageflip/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		<enclosure url="https://www.bram.us/wordpress/wp-content/uploads/2026/09/hic-pageflip.mp4" length="1280297" type="video/mp4" />

			</item>
		<item>
		<title>Feature Detecting “Undetectable” CSS Features with @supports named-feature()</title>
		<link>https://www.bram.us/2026/08/27/feature-detecting-undetectable-css-features-with-supports-named-feature/</link>
					<comments>https://www.bram.us/2026/08/27/feature-detecting-undetectable-css-features-with-supports-named-feature/#comments</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 21:31:42 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[feature detection]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36574</guid>

					<description><![CDATA[<p>In CSS you can feature detect support for things like selectors, properties + values, and <a href="https://brm.us/at-rule">at-rules</a> using <code>@supports</code>. But how do you feature detect a change to an underlying implementation, or two existing properties suddenly working together? Enter <code>@supports named-feature()</code>, a function designed to expose these specific capabilities without relying on hacky workarounds.</p>]]></description>
										<content:encoded><![CDATA[<p><a href="https://www.bram.us/wordpress/wp-content/uploads/2026/08/@supports_named-feature.png"><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/08/@supports_named-feature.png" alt="" width="560" height="332" class="alignnone size-medium wp-image-36614" /></a></p>
<div class="intro">
<p>In CSS you can feature detect support for things like selectors, properties + values, and <a href="https://brm.us/at-rule">at-rules</a> using <code>@supports</code>. But how do you feature detect a change to an underlying implementation, or two existing properties suddenly working together? Enter <code>@supports named-feature()</code>, a function designed to expose these specific capabilities without relying on hacky workarounds.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>The OG use-case: “Flexbox Gap”</h3>
<p>Back in the day <em>(and by back in the day, I mean 2020 😅)</em>, I wrote about <a href="https://www.bram.us/2020/05/12/spacing-grid-flexbox-items-in-css-with-the-gap-property/">the <code>gap</code> property for Flex layouts landing in Chromium</a>. The property already worked with Grid Layouts, and the post celebrated the property also starting to have an effect on Flex layouts.</p>
<p>Historically, this <code>gap</code> property <a href="https://lists.w3.org/Archives/Public/www-style/2015Jun/0221.html">debuted in 2015</a> as <code>grid-gap</code> <em>(as a shorthand for <code>grid-row-gap</code> and <code>grid-column-gap</code>)</em>, and worked exclusively for CSS Grid Layout. Later on, in 2017, driven by author demand, the CSS Working Group <a href="https://github.com/w3c/csswg-drafts/issues/1036#issuecomment-320241018">decided</a> that spacing out items was useful across all layout methods. So they dropped the <code>grid-</code> prefix and created the shorthand <code>gap</code> property (and its longhands <code>row-gap</code> and <code>column-gap</code>) that applies to both Grid and Flex layouts.</p>
<pre><code class="language-diff">- grid-gap: 2em;
+ gap: 2em;</code></pre>
<p>Browsers followed suit, and started treating <code>grid-gap</code> as an alias for the newly created <code>gap</code> property. But — BUT! — that didn’t mean that <code>gap</code> also immediately worked with Flex layouts in browsers. While aliasing <code>grid-gap</code> to <code>gap</code> was quick and easy, there was a massive <em>gap</em> (pun intended 🥁) between it being specced to work with Flex layout and a browser actually supporting “Flexbox gap”.</p>
<p>During this limbo, there was no straightforward way to detect whether the browser supported “Flexbox gap” or not. You couldn&#8217;t do <code>@supports (gap: 1em)</code> because the browser would truthfully say <em>“Yes! I support that!”</em> (for Grid). You also couldn’t do <code>@supports (display: flex)</code>, as the browser also said yes. And combining both — through <code>@supports (display: flex) and (gap: 1em)</code> — also didn’t work because the browser only checks whether it can <em>parse</em> the passed in <code>&lt;suppports-condition&gt;</code>s, not whether they have a combined effect.</p>
<pre><code class="language-css">/* This only checks if CSS can parse these declarations, not if they work together */
@supports (display: flex) and (gap: 1em) {
  …
}</code></pre>
<p>So right after Chromium shipped “Flexbox Gap” in 2020, I filed <a href="https://github.com/w3c/csswg-drafts/issues/5062">w3c/csswg-drafts#5062</a> at the CSS Working Group asking how to properly feature detect <code>gap</code> also playing nice with Flexbox.</p>
<p>Turns out this was already on their radar, and the request got duped into the more generically worded <a href="https://github.com/w3c/csswg-drafts/issues/3559">w3c/csswg-drafts#3559</a> from 2019, which was about <em>“testing support of properties and values with partial implementations”</em>.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Enter <code>named-feature()</code></h3>
<p>The discussion in <a href="https://github.com/w3c/csswg-drafts/issues/3559">w3c/csswg-drafts#3559</a> eventually turned silent but at the end of 2023 <a href="https://github.com/w3c/csswg-drafts/issues/3559#issuecomment-1868151989">it became very relevant again</a> as browsers were about to support <code>align-content</code> having an effect on <code>display: block</code> containers.</p>
<p>Two months later, the Working Group <a href="https://github.com/w3c/csswg-drafts/issues/3559#issuecomment-1885332213">accepted</a> a <a href="https://github.com/w3c/csswg-drafts/issues/3559#issuecomment-1868169502">proposal by David Baron</a> to solve this. I really like David’s original pitch to solving this problem:</p>
<blockquote>
<p>I think it might be reasonable to add specific, one-off solutions when we think they&#8217;re important enough. They&#8217;d need to be for significant enough features that we&#8217;d be willing to add an extra keyword to CSS for them, and that an implementor wouldn&#8217;t miss that it needed to be implemented, and that we&#8217;d be willing to write specific web-platform-tests to verify and monitor the results closely to make sure the rollout doesn&#8217;t go wrong. And we could probably choose reasonably verbose keywords.</p>
</blockquote>
<p><mark>The result is <code>named-feature()</code>, which is a function that allows you to feature-detect very specific behaviors or behavior changes that cannot be caught using the traditional <code>@supports</code>-checks. The function require a keyword as its argument, and the list of allowed keywords is <a href="https://drafts.csswg.org/css-conditional-5/#dfn-support-named-feature">predefined in the spec</a>.</mark></p>
<pre><code class="language-css">@supports named-feature(some-specific-behavior) {
  /* CSS for browsers that support 'some-specific-behavior' here … */
}</code></pre>
<p>Keywords are only added for the handful of tricky edge cases where feature detection is highly requested but impossible to do using other mechanisms in CSS.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>Real-World Use Cases</h3>
<p>Currently the CSS Working Group has specified two keywords for <code>named-feature()</code>. I&#8217;m quite happy about these, as I proposed both of them at the Working Group 🙂</p>
<p style="text-align: center; font-size: 14; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>1. Transform-Aware Anchor Positioning</h4>
<p>CSS Anchor Positioning initially shipped without taking transforms into account. Because author demand was huge, things changed and the spec got updated to support this use-case. The change <a href="https://www.bram.us/2025/11/20/anchor-positioning-is-transform-aware-in-chrome-144/">shipped in Chrome 144</a>, is <a href="https://developer.apple.com/documentation/safari-release-notes/safari-27-release-notes#:~:text=Added%20support%20for%20transform%2Daware%20anchor%20positioning.%20(175401339)">included in the upcoming Safari 27</a>, and is <a href="https://groups.google.com/a/mozilla.org/g/dev-platform/c/ESPZgAnSDss/m/0cf-kcITEAAJ?e=48417069" target="_blank" rel="noopener">currently being prototyped in Firefox</a>.</p>
<p>While you could already easily feature detect Anchor Positioning itself, you couldn&#8217;t detect if the browser respects transforms on the anchor. To solve this, you can now use the keyword that <a href="https://github.com/w3c/csswg-drafts/issues/13678">was resolved on in w3c/csswg-drafts#13678</a>: <code>anchor-position-follows-transforms</code>.</p>
<pre><code class="language-css">@supports named-feature(anchor-position-follows-transforms) {
  /* ✅ The browser supports transform-aware anchor positioning! */
}</code></pre>
<p>While not originally slated for their 27.0 release — the release that includes Transform-Aware Anchor Positioning — I reached out to the Safari team to add support for <code>named-feature(anchor-position-follows-transforms)</code>, and <a href="https://github.com/WebKit/WebKit/pull/70571">the PR got merged earlier this month</a>. Firefox is currently prototyping Transform-Aware Anchor Positioning, and they <a href="https://groups.google.com/a/mozilla.org/g/dev-platform/c/2Xn5rT6tZBc/m/U-9oQhIUEAAJ">announced</a> that they will ship <code>named-feature()</code> simultaneously.</p>
<p style="text-align: center; font-size: 14; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h4>2. Single-Axis Scroll Containers</h4>
<p>Another feature that needs more advanced feature detection is the recent change that allows containers to be a scroller for only a single axis. This fixes a longstanding issue where <code>position: sticky</code> on one axis gets trapped by an unrelated scroller on the other axis, as detailed in my post on <a href="https://www.bram.us/2026/03/30/css-sticky-per-axis/">CSS sticky per axis</a>.</p>
<p>While you can feature detect Single-Axis Scroll Containers by <a href="https://github.com/w3c/csswg-drafts/issues/13677#issuecomment-4118565796">throwing lots of JavaScript at the problem</a>, there was no way to do this in CSS. But thanks to <a href="https://github.com/w3c/csswg-drafts/issues/13677">my proposal in w3c/csswg-drafts#13677</a>, we now have the <code>single-axis-scroll-container</code> keyword for use with <code>@supports named-feature(single-axis-scroll-container)</code>:</p>
<pre><code class="language-css">@supports named-feature(single-axis-scroll-container) {
  /* ✅ Single-axis scroll containers are supported, so sticky per axis just works! */
}</code></pre>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="#browser-support" name="browser-support">#</a> Browser Support</h3>
<div class="note note--info">
<p>💡 Although this post was originally published in August 2026, the list below is constantly being updated. <em>Last update: August 27, 2026</em>.</p>
</div>
<p>Support for <code>named-feature()</code> looks as follows:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>✅ Supported in Chrome 150</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>☑️ Supported in Firefox 156 (soon in beta)</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>☑️ Supported in Safari Technology Preview</p>
</dd>
</dl>
<p>What is more interesting though, is support for the various keywords.</p>
<p>Support for <code>anchor-position-follows-transforms</code>:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>✅ Supported in Chrome 150</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>☑️ Supported in Firefox 156 (soon in beta)</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>☑️ Supported in Safari Technology Preview</p>
</dd>
</dl>
<p>Support for <code>single-axis-scroll-container</code>:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>✅ Supported in Chrome 153</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>❌ No Support. There is no bug tracking this yet.</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>❌ No Support. There is no bug tracking this yet.</p>
</dd>
</dl>
<div class="note">
<p>One unfortunate thing to note here that you&#8217;ll get false negatives for <code>anchor-follows-transforms</code> Chrome 144–150 and Safari 27 as there was a gap between shipping the feature itself and the feature detection part with <code>named-feature()</code>.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3>What about other features?</h3>
<p>If you are wondering about the older features I mentioned here, and why they don’t have a keyword, here goes:</p>
<dl>
<dt>CSS <code>gap</code> with <code>display: flex</code></dt>
<dd>All browser support this by now. Too much time has passed since it shipped and this feature detection through <code>named-feature()</code> becoming available.</dd>
<dt><code>align-content</code> having an effect on <code>display: block</code></dt>
<dd>Chrome did it’s homework and did <em>(internal)</em> testing against the top X websites. The only notable breakage that came out of that was Netflix being broken <em>(they had <code>align-content</code> on an element that switched from <code>block</code> to <code>flex</code> through a media query)</em> but we got them to fix their CSS.</dd>
</dl>
<p>And then there’s also these features that are currently hard to detect:</p>
<dl>
<dt>Style Queries</dt>
<dd>
<dd>
<p>I have filed <a href="https://github.com/w3c/csswg-drafts/issues/13975">w3c/csswg-drafts#13975</a> for it a while ago. The tricky thing with it though, is that a lot of time has passed since Chrome first shipped it in March 2023 and Safari in September 2024 … so shipping a check now would yield a lot of false negatives. Additionally, there is <a href="https://www.bram.us/2024/10/06/feature-detect-style-queries-support-in-css/">a documented workaround to feature detect style queries</a>.</p>
</dd>
</dl>
<p>For completeness: Other at-rules — such as <code>@property</code> — can be feature detected using <a href="https://brm.us/at-rule"><code>at-rule()</code></a>.</p>
<div class="note">
<p>Even with <code>at-rule()</code> and <code>named-feature()</code> now existing, some things are still not covered by. For example, you can’t use those to feature detect support for <code>scrolled</code> scroll-state queries <em>(see <a href="https://brm.us/hidey-bar-2">The Hidey Bar</a>)</em>. While one could argue to add an extra keyword to <code>named-feature()</code>, the thing that will solve this are <a href="https://github.com/w3c/csswg-drafts/issues/12622">custom supports conditions (<code>@supports-condition</code>)</a>. At the time of writing, that feature only has a WG resolution though.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="spread-the-word">Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3mu3skq6dak2c">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117169627581544218">🦣 Mastodon</a></li>
<li><a href="https://lnkd.in/p/ejJRP2YH">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/08/27/feature-detecting-undetectable-css-features-with-supports-named-feature/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>The Future of CSS: Target Multiple Classes with the Class Prefix Selector</title>
		<link>https://www.bram.us/2026/08/20/the-future-of-css-target-multiple-classes-with-the-class-prefix-selector/</link>
					<comments>https://www.bram.us/2026/08/20/the-future-of-css-target-multiple-classes-with-the-class-prefix-selector/#comments</comments>
		
		<dc:creator><![CDATA[Bramus!]]></dc:creator>
		<pubDate>Wed, 19 Aug 2026 22:35:47 +0000</pubDate>
				<category><![CDATA[Original Content]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[selectors]]></category>
		<guid isPermaLink="false">https://www.bram.us/?p=36541</guid>

					<description><![CDATA[<p>To target multiple classes that share the same prefix, you'd typically have to resort to adding an extra base classes to your markup or to using badly performing attribute selectors. To make things easier, CSS is getting a new selector: The Class Prefix Selector (<code>.prefix-*</code>).</p>]]></description>
										<content:encoded><![CDATA[<p><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2026/08/class-prefix-selector.png" alt="" width="560" height="372" class="alignnone size-medium wp-image-36553" /></a></p>
<div class="intro">
<p>To target multiple classes that share the same prefix, you&#8217;d typically have to resort to adding an extra base classes to your markup or to using badly performing attribute selectors. To make things easier, CSS is getting a new selector: The Class Prefix Selector (<code>.prefix-*</code>).</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note note--warning">
<p><strong>⚠️ This post is about an upcoming CSS feature. You can’t use it … yet.</strong></p>
<p>This feature is hot off the press — <a href="https://github.com/w3c/csswg-drafts/issues/10001#issuecomment-5204871059">it was resolved on only two weeks ago</a> — and currently only exists in <a href="https://drafts.csswg.org/selectors-5/#class-prefix">spec text</a>. The spec will most likely see some changes before this is ready for a browser to implement.</p>
<p>If you have any feedback on the shape of this in-development feature, leave feedback below or at the CSS Working Group in <a href="https://github.com/w3c/csswg-drafts/issues/10001">w3c/csswg-drafts#10001</a></div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="the-problem">The Problem: Targeting Multiple Prefixed Classes</h3>
<p>When coming up with classnames for use in the <code>class</code> attribute, a common practice is to use a prefix to retain some grouping or hierarchy. You might be familiar with classes like <code>.btn-primary</code>, <code>.btn-secondary</code>, <code>.btn-danger</code>, and so on.</p>
<p>To apply a base style to all of these buttons today, you typically have to list them all out, or introduce a separate <code>.btn</code> base class:</p>
<pre><code class="language-css">/* Adding a base class */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}

/* Or listing everything... yuck! */
.btn-primary,
.btn-secondary,
.btn-danger {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}</code></pre>
<p>Some of you even resort to substring-matching attribute selectors, but those selectors perform badly:</p>
<pre><code class="language-css">/* Works, but performs badly */
[class^="btn-"],
[class*=" btn-"] {
  padding: 0.5rem 1rem;
}</code></pre>
<p>Looking at <a href="https://chrome.dev/css-selector-benchmark/benchmarks/btn/">a benchmark</a> I ran (<a href="https://gist.github.com/bramus/1de3bc824ea3d9b47540b023dc165723">CLI results here</a>) using <a href="https://github.com/GoogleChromeLabs/css-selector-benchmark"><code>css-selector-benchmark</code></a>, a regular class selector ran at more than 6000 runs/s, whereas <code>[class*=" btn-"]</code> dipped as low as 328 runs/s … that’s almost 20 times slower and also 3ms out of your frame budget just to match an element!</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="the-solution">The Solution: The Class Prefix Selector</h3>
<p>Just two weeks ago, at the CSS Working Group F2F meeting in Berlin (August 2026), we resolved to add a dedicated <strong>Class Prefix Selector</strong> to the CSS Selectors Level 5 specification. The idea was originally pitched by <a href="http://lea.verou.me/">Lea Verou</a> back in 2024 <em>(<a href="https://github.com/w3c/csswg-drafts/issues/10001">w3c/csswg-drafts/#10001</a>)</em>, and also championed by Lea (and <a href="https://tabatkins.com/">Tab Atkins—Bittner</a>) at the F2F.</p>
<p>The syntax is incredibly straightforward:</p>
<pre><code class="language-css">.btn-* {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}</code></pre>
<p>That’s it! The <code>-*</code> part at the end makes the selector a <strong>Class Prefix Selector</strong> and will try to  match any class that begins with that hyphen-separated prefix.</p>
<p>It’s a huge win for utility classes and design systems, allowing you to easily target groups of related elements without having to bloat your HTML payload or write fragile attribute selectors.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="empty-string">What about the empty string?</h3>
<p>An interesting question that popped up during the discussions is whether <code>.foo-*</code> should match the empty string <em>(<a href="https://github.com/w3c/csswg-drafts/issues/14291">w3c/csswg-drafts/#14291</a>)</em>, meaning: should <code>.foo-*</code> also match an element that <em>merely</em> has the <code>.foo-</code> class?</p>
<p>While the exact default behavior is still being ironed out, currently the selector is specified to only match classes that start with the prefix and that have at least one character beyond the prefix <em>(and the first such character beyond the prefix is not also a hyphen)</em></p>
<p>So no, <code>class="foo-"</code> would NOT be matched by <code>.foo-*</code>, which I think is fine. That same selector also would not match <code>class="foo--"</code>, which is also probably fine.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="non-dashes">What about non-dashes?</h3>
<p>The Class Prefix Selector is currently limited to hyphen-separated prefixes, at least at first. Other separators, like <code>_</code>, might be added as possibilities in the future as we receive request from authors like yourself about what would be needed.</p>
<p>One thing that is already quite clear right now, is that there must at least be <em>some</em> separator. Arbitrary prefixes <em>(like <code>.foo*</code>)</em> are not going to be allowed for at least two reasons:</p>
<ol>
<li>You could accidentally overselect: <code>.foo*</code> would also match <code>.footer</code></li>
<li>Selector Performance: Browsers typically create buckets for class selectors for quick selector matching. Adding arbitrary wildcards defeat that optimization entirely. With the <code>-</code> as a separator, the browser can already create extra buckets when parsing the HTML, long before CSS ever gets parsed and starts matching.</li>
</ol>
<p>Similarly, wildcards in the middle of a selector (such as <code>.card-*-primary</code>) are also not going to be allowed.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3 id="why-not-the-pipe">Why not reuse the <code>|=</code> selector?</h3>
<div class="note">
<p>This section was added on 2026.08.21, after Brian <a href="https://bsky.app/profile/bkardell.com/post/3mtjenjw7s22p">asked</a> about this</p>
</div>
<p>In the issue thread and during the CSSWG call, the idea of reusing <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Attribute_selectors#attrvalue_3">the existing &#8220;dash match&#8221; attribute selector (<code>|=</code>)</a> was brought up. Instead of introducing the new <code>.foo-*</code> syntax, what if we just wrote <code>[class|="foo"]</code>?</p>
<p>I also initially thought this would be a good idea, but the working group ultimately decided against it for valid reasons.</p>
<p>First of all, there are a few issues with the <code>|=</code> selector that prevent it from being a drop-in solution for wildcard class matching:</p>
<ul>
<li>The <code>|=</code> operator was originally created for language attributes (like <code>[lang|="en"]</code>). By design, it matches the hyphenated prefix (like <code>en-us</code>) <em>but also the exact value</em> (<code>en</code>). As argued on the call, this is a massive footgun for utility classes: if you want to target all <code>.bi-*</code> icons, you probably don&#8217;t want those styles accidentally leaking to a standalone <code>.bi</code> base class.</li>
<li>To make the <code>|=</code> selector work for class matching, its behavior would need to change. The way the selector currently works is by only checking values from the <em>very beginning</em> of an attribute&#8217;s string. If you have an element with multiple classes like <code>&lt;div class="card btn-primary"&gt;</code>, the selector <code>[class|="btn"]</code> would completely fail to match it because <code>btn-primary</code> isn&#8217;t at the start of the <code>class</code> attribute.</li>
<li>Selector Performance <em>(see previous mentions about this)</em>.</li>
</ul>
<p>The most important reason, though, is The Bigger Picture™ for wildcards in CSS: there is an ongoing, larger effort within the CSSWG (also championed by Lea) to <a href="https://github.com/w3c/csswg-drafts/issues/14224">standardize wildcards across all of CSS</a></p>
<p>By <a href="https://github.com/w3c/csswg-drafts/issues/14224#issuecomment-5177254686">choosing <code>-*</code> as the syntax for prefixes</a>, the syntax can later be reused for future extensions such as wildcard attribute <em>names</em> (e.g. <code>[data-*]</code>) and wildcard (custom) element names (e.g. <code>custom-framework-*</code>).</p>
<p>Because the <code>|=</code> selector only works for attribute <em>values</em>, it is effectively a dead end for those other use cases.</p>
<div class="note">
<p>For completeness: Right after we discussed the issue at the CSS WG, <a href="https://github.com/w3c/csswg-drafts/issues/14289">w3c/csswg-drafts#14289</a> was filed to explore if we can relax <code>|=</code> so that it fits the bill. <em>If</em> that can be done, it would not tick the The Bigger Picture™ box, though.</p>
</div>
<hr>
<h3><a href="#browser-support" name="browser-support">#</a> Browser Support</h3>
<div class="note note--info">
<p>💡 Although this post was originally published in August 2026, the list below is constantly being updated. <em>Last update: August 20, 2026</em>.</p>
</div>
<p>Since this was literally just resolved at the CSSWG F2F in Berlin two weeks ago, browser support is currently non-existent. To follow along with the progress – if any – you can follow these browser issues:</p>
<dl class="grid">
<dt>Chromium <em>(Blink)</em></dt>
<dd>
<p>❌ No Support</p>
<p>Subscribe to <a href="https://crbug.com/550093337">CrBug #550093337</a> to follow along.</p>
</dd>
<dt>Firefox <em>(Gecko)</em></dt>
<dd>
<p>❌ No Support</p>
<p>There is no bug tracking this yet.</p>
</dd>
<dt>Safari <em>(WebKit)</em></dt>
<dd>
<p>❌ No Support</p>
<p>There is no bug tracking this yet.</p>
</dd>
</dl>
<p>This feature is still in its early days and needs to be fleshed out further, so could be that it takes a few more years before you can use it in production …</p>
<hr>
<h3><a href="#feature-detection" name="feature-detection">#</a> Feature Detection</h3>
<p>You can feature detect support with a regular <code>@supports</code> rule:</p>
<pre><code class="language-css">@supports selector(.foo-*) {
  /* Browser has support */
}</code></pre>
<p>The following CodePen uses this and will light green when you browser supports it:</p>
<p class="codepen" data-height="520" data-pen-title="CSS Class Prefix Selector Support test" data-default-tab="result" data-slug-hash="qERerxM" data-user="bramus" style="height: 520px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/bramus/pen/qERerxM"><br />
  CSS Class Prefix Selector Support test</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br />
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<p><script async src="https://public.codepenassets.com/embed/index.js"></script></p>
<hr>
<h3 id="spread-the-word">Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3mthseyexik2r">🦋 Bluesky</a></li>
<li><a href="https://front-end.social/@bramus/117124577120728021">🦣 Mastodon</a></li>
<li><a href="https://lnkd.in/p/eGvCJhXH">💼 LinkedIn</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://www.bram.us/2026/08/20/the-future-of-css-target-multiple-classes-with-the-class-prefix-selector/feed/</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
	</channel>
</rss>
