Bridgetown2026-01-02T01:00:28+00:00https://fractaledmind.github.io/feed.xmlFractaled MindThis is my personal site, where I write about Ruby, programming, and any of my varied fascinations.Stephen MargheimWriting Tailwind-compatible Semantic CSS2026-01-02T00:00:00+00:002026-01-02T00:00:00+00:00repo://posts.collection/_posts/2026-01-02-writing-tailwind-compatible-semantic-css.md<p>Building HTML UI forced me to figure out how to write reusable CSS classes that play nice with Tailwind. Along the way, I looked at how other libraries tackle this. Spoiler: most of them get it wrong.</p> <!--/summary--> <hr /> <p>Let me show you two approaches I found, then I’ll show you what I landed on.</p> <p>Here’s how <a href="https://github.com/hunvreus/basecoat">Basecoat</a> defines a badge:</p> <pre><code class="language-css">@layer components { .badge, .badge-primary, .badge-secondary, .badge-destructive, .badge-outline { @apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&amp;&gt;svg]:size-3 gap-1 [&amp;&gt;svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden; } } </code></pre> <p>One line. Every variant lumped together. State styles crammed in with bracket notation.</p> <p><a href="https://github.com/saadeghi/daisyui">DaisyUI</a> does something kinda similar but also notably different:</p> <pre><code class="language-css">.badge { @layer daisyui.l1.l2.l3 { @apply rounded-selector inline-flex items-center justify-center gap-2 align-middle; color: var(--badge-fg); border: var(--border) solid var(--badge-color, var(--color-base-200)); font-size: 0.875rem; width: fit-content; background-size: auto, calc(var(--noise) * 100%); background-image: none, var(--fx-noise); background-color: var(--badge-bg); --badge-bg: var(--badge-color, var(--color-base-100)); --badge-fg: var(--color-base-content); --size: calc(var(--size-selector, 0.25rem) * 6); height: var(--size); padding-inline: calc(var(--size) / 2 - var(--border)); } } </code></pre> <p>Mixes <code>@apply</code> with raw CSS. Custom properties everywhere. Nested inside a layer with a bizarre naming scheme.</p> <h2 id="whats-wrong">What’s Wrong</h2> <p>Both approaches share three problems:</p> <table> <thead> <tr> <th>Problem</th> <th>Why It Hurts</th> </tr> </thead> <tbody> <tr> <td><strong>No tree-shaking</strong></td> <td><code>@layer</code> ships everything, used or not. Define 20 classes, ship 20 classes.</td> </tr> <tr> <td><strong>No autocomplete</strong></td> <td>Tailwind’s IntelliSense doesn’t know these classes exist. Developers can’t discover them.</td> </tr> <tr> <td><strong>Unreadable states</strong></td> <td>All those <code>focus-visible:</code> and <code>dark:aria-invalid:</code> prefixes become a wall of noise.</td> </tr> </tbody> </table> <h2 id="my-approach">My Approach</h2> <p>Here’s how I write my badge class in HTML UI:</p> <pre><code class="language-css">@utility ui-badge { :where(&amp;) { @apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 transition-[color,box-shadow] overflow-hidden border-transparent bg-primary text-primary-foreground; &amp; &gt; svg { @apply size-3 pointer-events-none; } @variant hover { @apply bg-primary/90; } @variant focus-visible { @apply border-ring ring-ring/50 ring-[3px]; } @variant aria-invalid { @apply ring-destructive/20 border-destructive; } } @variant dark { :where(&amp;) { @variant aria-invalid { @apply ring-destructive/40; } } } } </code></pre> <p>Same basic visual result. Completely different structure. Let me break down why this is better.</p> <hr /> <h2 id="utility-gives-you-tree-shaking-and-autocomplete"><code>@utility</code> gives you tree-shaking and autocomplete</h2> <p>Classes defined with <code>@utility</code> only ship if they’re used in your markup. Define twenty, use three, ship three. That’s the essential Tailwind contract, and <code>@layer</code> breaks it.</p> <p><code>@utility</code> also registers with IntelliSense. Type your prefix in your editor and see every affordance. Discoverability matters. If developers can’t find your class, they’ll reinvent it inline.</p> <h2 id="a-prefix-like-ui--makes-affordances-obvious">A prefix like <code>ui-</code> makes affordances obvious</h2> <p>When you see <code>.btn</code> in a codebase, you have no idea what you’re dealing with. Is it Bootstrap? Some old semantic class with unpredictable specificity? A utility?</p> <p>A prefix solves this. <code>ui-button</code> signals intent: this is a zero-specificity visual pattern designed to compose with utilities. It’s not last decade’s semantic CSS.</p> <p>The prefix also makes autocomplete useful. Type <code>ui-</code> and you see every affordance in the system. Pick whatever convention works for your team—<code>af-</code>, <code>look-</code>, whatever—but having <em>a</em> convention communicates that these classes play by different rules.</p> <h2 id="where-gives-you-zero-specificity"><code>:where()</code> gives you zero specificity</h2> <p>Utilities in <code>@utility</code> live in Tailwind’s utilities layer—highest priority. That’s normally what you want. But affordance classes should be <em>overridable</em> by utilities.</p> <p><code>:where()</code> contributes zero specificity. So <code>:where(.ui-badge)</code> has specificity <code>0,0,0</code>, while <code>bg-red-500</code> has <code>0,1,0</code>. The utility always wins:</p> <pre><code class="language-html">&lt;span class="ui-badge bg-red-500"&gt;Error&lt;/span&gt; </code></pre> <p>No <code>!important</code>. No cascade conflicts. The affordance provides defaults; utilities customize.</p> <h2 id="variant-gives-you-readable-state-styles"><code>@variant</code> gives you readable state styles</h2> <p>Compare the Basecoat approach:</p> <pre><code class="language-css">@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40; </code></pre> <p>To this:</p> <pre><code class="language-css">@variant focus-visible { @apply border-ring ring-ring/50 ring-[3px]; } @variant aria-invalid { @apply ring-destructive/20 border-destructive; } @variant dark { :where(&amp;) { @variant aria-invalid { @apply ring-destructive/40; } } } </code></pre> <p>Each state gets its own block. You can see at a glance what changes on focus, what changes when invalid, what changes in dark mode. The structure matches how you <em>think</em> about states.</p> <p>There’s also a practical reason: <code>@apply hover:bg-red-500</code> <a href="https://github.com/tailwindlabs/tailwindcss/discussions/17993">can break in Svelte/Vue <code>&lt;style&gt;</code> blocks</a> because the colon gets parsed as CSS syntax before Tailwind processes it. <code>@variant</code> sidesteps this entirely.</p> <h2 id="apply-makes-compatibility-a-non-issue"><code>@apply</code> makes compatibility a non-issue</h2> <p>You might wonder why I use <code>@apply</code> instead of raw CSS. The answer is compatibility.</p> <p>When you write <code>@apply bg-primary text-sm px-2</code>, you’re referencing the user’s Tailwind theme. Their colors. Their spacing scale. Their typography. If they’ve customized <code>primary</code> to be orange instead of indigo, your affordance automatically uses orange. If they use a non-standard spacing scale, <code>px-2</code> resolves to whatever <em>they</em> defined.</p> <p>DaisyUI’s approach—defining its own custom properties like <code>--badge-fg</code> and <code>--color-base-200</code>—creates a parallel design system. Users have to map their tokens to DaisyUI’s tokens. That’s friction.</p> <p><code>@apply</code> eliminates that friction. Your affordances speak the same language as the user’s utilities because they <em>are</em> the user’s utilities, just composed.</p> <hr /> <p>Tailwind v4’s <code>@utility</code>, <code>@apply</code>, and <code>@variant</code> directives aren’t just new syntax. Combined with <code>:where()</code>, they let you write semantic CSS classes that are discoverable, tree-shakeable, readable, and composable with utilities.</p> <p>That’s the approach. Now go build something.</p>Stephen Margheim2025 in review2025-12-31T00:00:00+00:002025-12-31T00:00:00+00:00repo://posts.collection/_posts/2025-12-31-year-in-review.md<p>On September 23rd, 2025, in a Berlin hospital room, I became a dad. Emma Elanor Margheim entered the world and promptly rearranged every priority I thought I had.</p> <p>She’s currently asleep, so let me tell you about the rest of the year.</p> <!--/summary--> <hr /> <h2 id="becoming-a-family">Becoming a Family</h2> <p>The year began with a milestone: on January 13th, Geniya became a German citizen. After oodles of paperwork and appointments and waiting, she walked out of the Ausländerbehörde as a dual citizen. We celebrated with sushi.</p> <p><button type="button" class="thumbnail" popovertarget="geniya-citizenship-jpeg" aria-label=""> <img src="/images/geniya-citizenship.jpeg" alt="Young woman standing between European Union, German, and Berlin flags holding a Berlin certificate, smiling in an official setting." style="width: 33%; margin-inline: auto;" /> </button></p> <dialog class="lightbox" id="geniya-citizenship-jpeg" popover=""> <img src="/images/geniya-citizenship.jpeg" alt="" /> </dialog> <p>By spring, we knew Emma was on the way. In June, we escaped to Südtirol for a babymoon—a last hurrah of lazy mornings and mountain views before our family of 2 become a family of 3. We hiked (more like walking, but in nature), ate too much, and tried to imagine what life would look like in a few months.</p> <p><button type="button" class="thumbnail" popovertarget="babymoon-jpeg" aria-label=""> <img src="/images/babymoon.jpeg" alt="Two people resting on a lakeside bench, sneakers facing turquoise water, forested mountains and blue sky with a few clouds in the background." style="width: 33%; margin-inline: auto;" /> </button></p> <dialog class="lightbox" id="babymoon-jpeg" popover=""> <img src="/images/babymoon.jpeg" alt="" /> </dialog> <p>Then September 23rd arrived, and we began to find out.</p> <p><button type="button" class="thumbnail" popovertarget="emma-elanor-jpeg" aria-label=""> <img src="/images/emma-elanor.jpeg" alt="Adult hands cradling a newborn foot with hospital ID band; baby's heel has hospital tag with her name and date of birth." style="width: 33%; margin-inline: auto;" /> </button></p> <dialog class="lightbox" id="emma-elanor-jpeg" popover=""> <img src="/images/emma-elanor.jpeg" alt="" /> </dialog> <p>What I didn’t fully appreciate until living it: Germany’s support system for new parents is remarkable, coming from someone raised in the States and just simply unaware of what the whole process could look like. A midwife—a Hebamme as they are called here—visited our apartment every single day for the first week after Emma was born. Then weekly for the next two months. She checked on Emma, checked on Geniya, answered our endless questions, and made those early weeks survivable. No bills. No insurance negotiations. Just care.</p> <p>Looking ahead, knowing that universal childcare exists here—that Emma will have a spot in a Kita—takes an enormous weight off our planning. Starting a family in Germany has meant never once worrying about medical debt. That peace of mind is hard to overstate.</p> <p>In November, my parents flew over from the States to meet their grand-daughter. A week of them holding Emma, of showing them our Berlin neighborhood, of watching my dad figure out the U-Bahn. It was the first time they’d seen our new life here up close; it was cozy and fun.</p> <hr /> <h2 id="the-career-arc">The Career Arc</h2> <p>2025 marked my second job change in just over two years. I’d joined <a href="https://test.io">Test IO</a> when I moved to Berlin in 2019 and spent five years there—as a senior engineer, then team lead, then engineering manager, and eventually director leading 40+ engineers across five teams. Near the end of 2024, I moved to <a href="https://prevail.ai">Prevail.ai</a> as a Senior Engineer; I wanted to get back into writing code daily. Smaller team, different challenges, back to building.</p> <p>Then in November this year, another shift: Principal Engineer at <a href="https://impruvon.com">Impruvon Health</a>. Healthcare tech, Ruby and Rails on the backend, real problems affecting real patients. The onboarding was dense—calls about integrations, architecture discussions, first tasks shipping within weeks. I’m still ramping up, but I’m contributing. Feels good.</p> <p>The through-line across all of it: I keep finding my way back to Rails, to Ruby, to teams trying to build something that matters.</p> <hr /> <h2 id="high-leverage-rails">High Leverage Rails</h2> <p>The biggest project I shipped this year wasn’t code—it was a course.</p> <p>In February, I launched <a href="https://highleverage.dev">High Leverage Rails</a> with <a href="https://aaronfrancis.com">Aaron Francis</a> and <a href="https://tryhardstudios.com">Try Hard Studios</a>. It’s a comprehensive course on building production-ready Rails applications with SQLite—the database I’ve been advocating for years.</p> <p>The thesis: learn the fundamentals deeply, and you can build anything quickly. The age of the starter kit is ending. Responsibility requires understanding.</p> <p>Working with Aaron was a highlight. He’s built an incredible media operation, and collaborating on something at that scale pushed me in new directions. <a href="https://hatchbox.io">Hatchbox</a> and <a href="https://honeybadger.io">Honeybadger</a> came on as sponsors. The launch went well. And now there are developers out there building real applications with Rails and SQLite because of something I made. Wild.</p> <hr /> <h2 id="open-source">Open Source</h2> <p>My open source work this year centered on a few key projects:</p> <p><a href="https://github.com/fractaledmind/acidic_job"><strong>Acidic Job</strong></a> continued to evolve—durable execution workflows for Active Job. The idea is simple: background jobs should be resilient to failures, restarts, and chaos. The implementation is… less simple. But it’s getting there, with RC releases throughout the year.</p> <p><a href="https://github.com/fractaledmind/chaotic_job"><strong>Chaotic Job</strong></a> emerged as a companion gem for testing job resilience. It lets you simulate failures, timeouts, and all the ways jobs can go wrong—so you can prove your workflows handle them correctly. I talked about it at Tropical on Rails and ChicagoRuby.</p> <p><a href="https://github.com/fractaledmind/solid_errors"><strong>Solid Errors</strong></a> hit v0.7.0 in June—a database-backed exception tracker for Rails. This release was special because it was almost entirely community-driven. PRs from contributors, issues identified by users, a release that felt collaborative.</p> <p><a href="https://github.com/fractaledmind/litestream-ruby"><strong>Litestream Ruby</strong></a> got similar treatment—v0.13.0 shipped with all community contributions. The SQLite ecosystem keeps growing.</p> <p>And then there’s <a href="https://github.com/yippee-fun/plume"><strong>Plume</strong></a>, my SQL parser for SQLite’s dialect. I spent months on this—learning parser patterns, hitting 37,000+ passing tests, creating syntax diagrams. It became my RubyKaigi talk and remains one of the most technically challenging things I’ve built.</p> <hr /> <h2 id="my-ruby-triathlon">My Ruby Triathlon</h2> <p>In April, I gave three talks on three continents in three consecutive weeks:</p> <ol> <li><a href="https://www.tropicalonrails.com"><strong>Tropical on Rails</strong></a> in São Paulo — <a href="https://www.youtube.com/watch?v=NGeyotdnJS4">“Resilient Jobs and Chaotic Tests”</a></li> <li><a href="https://wrocloverb.com"><strong>wroclove.rb</strong></a> in Wrocław — <a href="https://www.youtube.com/watch?v=VWDfeMHBaH0">“On the tasteful journey to Yippee”</a> (a project <a href="https://joel.drapper.me">Joel Drapper</a> and I are slowly working on)</li> <li><a href="https://rubykaigi.org/2025/"><strong>RubyKaigi</strong></a> in Matsuyama — <a href="https://www.youtube.com/watch?v=VaSpF9JmbZo">“Parsing and generating SQLite’s SQL dialect with Ruby”</a></li> </ol> <p>I called it my #RubyTriathlon. Geniya called it “that thing where you’re gone for most of April.”</p> <p>The highlight was RubyKaigi and getting to hang out with the Ruby community in Japan, watching Matz talk about the future of Ruby, eating incredible food, and wandering Matsuyama. The lowlight was the 12-hour-33-minute flight from Warsaw to Tokyo, my longest ever.</p> <p><button type="button" class="thumbnail" popovertarget="japan-jpeg" aria-label=""> <img src="/images/japan.jpeg" alt="Narrow, dimly lit Japanese alley at night lined with small bars and shops, neon signs and lanterns, cluttered pipes and signage leading into distance" style="width: 33%; margin-inline: auto;" /> </button></p> <dialog class="lightbox" id="japan-jpeg" popover=""> <img src="/images/japan.jpeg" alt="" /> </dialog> <p>Later: SQLite Office Hours at <a href="https://railsconf.org">RailsConf</a> with <a href="https://mike.daless.io">Mike Dalessio</a>, a talk at <a href="https://chicagoruby.org">ChicagoRuby</a>. Five speaking engagements. Four continents. One very tired me.</p> <hr /> <h2 id="staying-connected">Staying Connected</h2> <p>Beyond conferences, the Ruby community showed up in smaller ways all year.</p> <p>In January and February alone, I had 19+ “Chat with Stephen” calls—video chats with developers from around the world. Some wanted to talk SQLite. Some wanted career advice. Some just wanted to connect. One of those calls was with <a href="https://twitter.com/taylorotwell">Taylor Otwell</a>, creator of <a href="https://laravel.com">Laravel</a>. I basically begged him to consider expanding the Laravel services to the Rails ecosystem. Maybe one day; a man can dream.</p> <p>I appeared on podcasts: <a href="https://remoteruby.com">Remote Ruby</a>, a few episodes of In Dialog, others I’m probably forgetting. I gave a virtual talk to <a href="https://rubyturkiye.org">Ruby Turkey</a>. I kept the <a href="https://discord.gg/zVKz9vrn"><strong>Naming Things Discord</strong></a> running—it’s invite-only, but it remains one of my favorite corners of the internet. Like a virtual hallway track at a Ruby conference.</p> <p>In December, after years of meaning to, I finally launched a <a href="https://join.fractaledmind.com/"><strong>newsletter</strong></a>. Added a signup form to the blog, sent my first issue. It felt like a missing piece clicking into place.</p> <hr /> <h2 id="writing">Writing</h2> <p>I published 6 blog posts this year, but the bigger development was launching a <a href="/tips/"><strong>Tips section</strong></a> in December. Short, focused techniques—one concept per post.</p> <p>The theme across all my writing: <strong>platform-native web development</strong>. Every month, browsers ship features that used to require JavaScript. I became obsessed with documenting what’s possible. Turns out: a lot.</p> <p>I will be doing a lot more in this space in 2026 for sure.</p> <hr /> <h2 id="life-in-berlin">Life in Berlin</h2> <p>Some snapshots from the year:</p> <p><strong>The movie pass.</strong> Early in the year, we got a <a href="https://www.uci-kinowelt.de">UCI Luxe</a> subscription—unlimited movies for a flat monthly fee. We saw everything: Nosferatu, Anora, A Real Pain, Emilia Perez, Mickey 17, Sinners, Final Destination 6, Ballerina. That last one, Demon Slayer, Geniya let me watch on my own while she was 37 weeks pregnant; she’s a champion. Once Emma arrived, the movie pass got canceled. Priorities shift. 🤷🏻</p> <p><strong>The studio.</strong> In July, I decided I needed a proper space for recording and calls. So I built one. Framed up a corner of our apartment, wired fans, hung insulation and acoustic panels. By August it was done—not perfect, but mine. As Aaron Francis says, you can just build things.</p> <div style="display: grid; grid-template-columns: repeat(5, 1fr); grid-gap: 10px;"> <button type="button" class="thumbnail" popovertarget="studio-build-0-jpeg" aria-label=""> <img src="/images/studio-build-0.jpeg" alt="Empty corner of a room with gray walls, light wood floor, white baseboard, electrical outlets, and a gray curtain at left; painter's tape marks on floor." /> </button> <button type="button" class="thumbnail" popovertarget="studio-build-1-jpeg" aria-label=""> <img src="/images/studio-build-1.jpeg" alt="Wooden stud wall framing installed on hardwood floor inside a modern apartment, with lighting fixtures, tools, and stacked panels nearby." /> </button> <button type="button" class="thumbnail" popovertarget="studio-build-2-jpeg" aria-label=""> <img src="/images/studio-build-2.jpeg" alt="Interior room with new wooden stud framing for a partition wall, exposed wiring, a cordless drill on the floor, ladder and modern ring lights above." /> </button> <button type="button" class="thumbnail" popovertarget="studio-build-3-jpeg" aria-label=""> <img src="/images/studio-build-3.jpeg" alt="Partially built interior partition framed with insulation batts, a glass door, small window opening and ventilation fan next to hardwood flooring." /> </button> <button type="button" class="thumbnail" popovertarget="studio-build-4-jpeg" aria-label=""> <img src="/images/studio-build-4.jpeg" alt="Modern room with a vertical slat wooden partition enclosing a glass-door nook, circular pendant lights overhead and hardwood floors with trim pieces on the floor." /> </button> </div> <dialog class="lightbox" id="studio-build-0-jpeg" popover=""> <img src="/images/studio-build-0.jpeg" alt="" /> </dialog> <dialog class="lightbox" id="studio-build-1-jpeg" popover=""> <img src="/images/studio-build-1.jpeg" alt="" /> </dialog> <dialog class="lightbox" id="studio-build-2-jpeg" popover=""> <img src="/images/studio-build-2.jpeg" alt="" /> </dialog> <dialog class="lightbox" id="studio-build-3-jpeg" popover=""> <img src="/images/studio-build-3.jpeg" alt="" /> </dialog> <dialog class="lightbox" id="studio-build-4-jpeg" popover=""> <img src="/images/studio-build-4.jpeg" alt="" /> </dialog> <p><strong>The opera.</strong> Swan Lake at <a href="https://deutscheoperberlin.de">Deutsche Oper Berlin</a> in March. Ein Sommernachtstraum earlier that month. A Sicilian cooking class in January. Ballet. These felt like the “before times” in retrospect—the last stretch of being able to do things spontaneously.</p> <p><strong>The license.</strong> After years in Germany, I finally got my driver’s license in July. Test drove a BYD. Still thinking about it.</p> <p><strong>Sports.</strong> The Eagles won the Super Bowl in February (Fly Eagles Fly). LSU won the College World Series in June (Geaux Tigers). I watched both from Berlin, at inconvenient hours, and regret nothing.</p> <hr /> <h2 id="by-the-numbers">By the Numbers</h2> <h3 id="travel">Travel</h3> <table> <tbody> <tr> <td><strong>Flights</strong></td> <td>21</td> </tr> <tr> <td><strong>Miles</strong></td> <td>43,142</td> </tr> <tr> <td><strong>Time in the air</strong></td> <td>98 hours</td> </tr> <tr> <td><strong>Countries visited</strong></td> <td>9</td> </tr> </tbody> </table> <p>I flew 1.7x around the Earth. Berlin appeared in 10 of my 21 flights. I never flew on a Friday—not once, for reasons I can’t explain.</p> <h3 id="writing-1">Writing</h3> <ul> <li>6 blog posts</li> <li>14 tips</li> <li>1 newsletter launched</li> </ul> <h3 id="speaking">Speaking</h3> <ul> <li>5 events</li> <li>4 continents</li> <li>3 conference talks, 1 workshop, 1 meetup</li> </ul> <h3 id="open-source-1">Open Source</h3> <ul> <li>15+ active repositories</li> <li>Major releases: Acidic Job, Chaotic Job, Solid Errors, Litestream Ruby, Plume</li> </ul> <hr /> <h2 id="what-i-learned">What I Learned</h2> <p>A few things became clear:</p> <p><strong>You can just build things.</strong> Studios. Parsers. Courses. The barrier is usually deciding to start.</p> <p><strong>The platform is getting really good.</strong> We need less than we think we do to build excellent web apps in 2026.</p> <p><strong>Community matters more than content.</strong> The best moments weren’t the talks. They were the conversations with old and new friends.</p> <p><strong>Support systems matter.</strong> Having a midwife visit daily, never worrying about medical bills, knowing childcare exists—these aren’t luxuries. They’re what let you focus on what matters.</p> <hr /> <h2 id="looking-ahead">Looking Ahead</h2> <p>As I write this, Emma is just over three months old. She’s has been smiling on purpose for a month or so now. She likes being held upright so she can look around.</p> <p>For 2026, I’m keeping it simple:</p> <ul> <li><strong>Keep writing.</strong> The Tips format works.</li> <li><strong>Keep speaking.</strong> I’m already scheduled to speak at <a href="https://rubyconfth.com">RubyConf Thailand</a>, and I’m on the program committee for <a href="https://www.rubyconf.at">RubyConf Austria</a>.</li> <li><strong>Keep shipping open source.</strong> I have some awesome stuff brewing that I can’t wait to share.</li> <li><strong>Be present.</strong> Emma’s first year only happens once.</li> </ul> <p>To everyone who read a post, attended a talk, joined a call, or sent a kind message this year: thank you. The Ruby community remains one of the best places on the internet.</p> <p>Here’s to 2026. May it be slightly less chaotic and equally wonderful.</p> <p>— Stephen</p>Stephen MargheimDialog Animation Gotchas2025-12-19T00:00:00+00:002025-12-19T00:00:00+00:00repo://posts.collection/_posts/2025-12-19-dialog-animation-gotchas.md<p>I spent way too long getting the animations right for my <a href="/2025/12/18/stylish-dialogs/">dialog post</a>. Chrome’s <a href="https://developer.chrome.com/blog/entry-exit-animations">documentation on entry/exit animations</a> made it look simple—define your open state, your starting state, your closed state. Three blocks of CSS. Done.</p> <p>The entry animation worked immediately. The exit was a disaster. The dialog snapped to full width mid-animation, jumped around, then vanished. The backdrop lingered after close, or disappeared instantly while the dialog was still fading. Nothing synced up.</p> <p>I want to walk through each problem I hit and how I fixed it, partly as documentation for my future self, partly because I suspect these same issues will bite anyone trying to animate native dialogs.</p> <!--/summary--> <hr /> <p>My first mistake was putting <code>@starting-style</code> in the wrong place. Chrome’s docs show it as a separate block, but I tried nesting it inside the base <code>dialog</code> selector:</p> <pre><code class="language-css">dialog { opacity: 1; scale: 1; transition: /* ... */ @starting-style { opacity: 0; scale: 0.95; } } </code></pre> <p>Nothing. No animation.</p> <p>The issue is that <code>@starting-style</code> defines where to animate <em>from</em> when an element enters a particular state. If you put it inside the base selector, it doesn’t know what state you’re entering. It needs to live inside <code>dialog[open]</code>:</p> <pre><code class="language-css">dialog[open] { opacity: 1; scale: 1; @starting-style { opacity: 0; scale: 0.95; } } dialog { transition: /* ... */ opacity: 0; scale: 0.95; } </code></pre> <p>The naming trips you up. “Starting style” sounds like “where this element starts”—the base state. But it means “where this element starts <em>when entering this specific state</em>.”</p> <p>Now the entry animation worked, but exit was still broken…</p> <hr /> <p>When the dialog closed, it snapped to full viewport width before animating out. I stared at this for a while before I thought to slow down the transition. Multiply your timings by 10x and you can actually <em>see</em> what’s happening frame by frame.</p> <p>The problem was obvious once I could watch it in slow motion. I had my layout styles on <code>dialog[open]</code>:</p> <pre><code class="language-css">dialog[open] { @apply flex w-full flex-col; @apply max-w-[calc(100vw-32px)]; @variant sm { @apply max-w-md; } } </code></pre> <p>The moment <code>[open]</code> gets removed, those constraints vanish. Mid-animation. The dialog loses <code>max-w-md</code> and expands while still fading out.</p> <p>The fix is to put layout properties in the base selector. Only <em>animation</em> properties should differ between states:</p> <pre><code class="language-css">dialog { @apply flex w-full flex-col; @apply max-w-[calc(100vw-32px)]; @variant sm { @apply max-w-md; } /* animation properties here */ } </code></pre> <p>This is the kind of thing that’s obvious once you internalize it, but easy to miss when you’re thinking about open vs. closed as entirely separate visual states. The dialog needs to maintain its structure throughout its entire lifecycle—including while it’s animating away.</p> <hr /> <p>Next problem: the backdrop. It would fade in nicely, then stick around after the dialog closed. Or vanish instantly while the dialog was still animating.</p> <p>The issue is that the backdrop’s <code>overlay</code> and <code>display</code> durations need to match the dialog’s. The color fade can be different, but the <em>visibility</em> timing has to stay in sync:</p> <pre><code class="language-css">dialog::backdrop { background-color: rgb(0 0 0 / 0); transition: background-color 0.05s ease-in-out, overlay 0.1s ease-in-out allow-discrete, display 0.1s ease-in-out allow-discrete; } dialog[open]::backdrop { background-color: rgb(0 0 0 / 0.2); transition: background-color 0.15s ease-in-out, overlay 0.2s ease-in-out allow-discrete, display 0.2s ease-in-out allow-discrete; @starting-style { background-color: rgb(0 0 0 / 0); } } </code></pre> <p>Here the backdrop fades out quickly (0.05s) but stays <em>visible</em> for 0.1s while the dialog finishes its exit. The color and the visibility are decoupled, which gives you flexibility in how the animation feels without breaking the synchronization.</p> <hr /> <p>At this point I had working animations, but they mirrored each other—slide up on entry, slide down on exit. I wanted asymmetry: slide up on entry, scale down in place on exit. Sliding down on exit felt wrong to me; it implies the dialog is <em>going</em> somewhere, but it’s not. It’s disappearing. Scale-and-fade says “dismissed” without the false movement.</p> <p>I tried different <code>transform</code> values for each state:</p> <pre><code class="language-css">dialog { transform: translateY(0) scale(0.95); /* Exit: just scale */ } dialog[open] { transform: translateY(0) scale(1); @starting-style { transform: translateY(20px) scale(0.98); /* Entry: slide up */ } } </code></pre> <p>Both animations ended up as just scales. CSS transitions animate the shortest path—the exit always reverses from <code>dialog[open]</code> back to <code>dialog</code>. You can’t get different paths with transitions alone.</p> <p>Keyframes solve this by letting you define completely independent animations:</p> <pre><code class="language-css">@keyframes dialog-slide-up-scale-fade { from { opacity: 0; transform: translateY(20px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes dialog-scale-down-fade { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(0) scale(0.95); } } dialog { animation: dialog-scale-down-fade 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards; transition: overlay 0.1s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete, display 0.1s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete; } dialog[open] { animation: dialog-slide-up-scale-fade 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; transition: overlay 0.2s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete, display 0.2s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete; @starting-style { animation: none; } } </code></pre> <p>Entry and exit are now independent. Transitions still handle <code>overlay</code> and <code>display</code> (they need <code>allow-discrete</code> because they’re discrete properties). The <code>@starting-style { animation: none; }</code> prevents the exit animation from firing on page load—without it, the dialog animates closed the moment the page renders.</p> <hr /> <p>One more issue: using <code>display: flex</code> on the base <code>dialog</code> meant it rendered briefly on page load. A flash of the styled dialog before it settled into its hidden state.</p> <p><code>pointer-events: none</code> fixed interaction but not the visual flash. The actual fix is to hide it properly:</p> <pre><code class="language-css">dialog { visibility: hidden; pointer-events: none; } dialog[open] { visibility: visible; pointer-events: auto; } </code></pre> <hr /> <p>Here’s where I landed. Timing lives in CSS variables so changing entry duration cascades everywhere:</p> <pre><code class="language-css">dialog { --dialog-entry-duration: 0.2s; --dialog-exit-duration: calc(var(--dialog-entry-duration) / 2); --backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.75); --backdrop-exit-duration: calc(var(--dialog-exit-duration) / 2); --dialog-easing: cubic-bezier(0.16, 1, 0.3, 1); --backdrop-easing: ease-in-out; /* Layout stays constant throughout lifecycle */ @apply flex w-full flex-col max-w-[calc(100vw-32px)]; @variant sm { @apply max-w-md; } visibility: hidden; pointer-events: none; animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards; transition: overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete, display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete; } dialog[open] { visibility: visible; pointer-events: auto; animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards; transition: overlay var(--dialog-entry-duration) var(--dialog-easing) allow-discrete, display var(--dialog-entry-duration) var(--dialog-easing) allow-discrete; @starting-style { animation: none; } } dialog::backdrop { background-color: rgb(0 0 0 / 0); transition: background-color var(--backdrop-exit-duration) var(--backdrop-easing), overlay var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete, display var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete; } dialog[open]::backdrop { background-color: rgb(0 0 0 / 0.2); transition: background-color var(--backdrop-entry-duration) var(--backdrop-easing), overlay var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete, display var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete; @starting-style { background-color: rgb(0 0 0 / 0); } } </code></pre> <p>Exit runs at half speed (entrances should feel intentional; exits get out of the way). Backdrop color fades faster than dialog content. Backdrop visibility stays synced with dialog.</p> <hr /> <p>None of this is complicated once you understand it. But it took me hours to get here, mostly because the failure modes are so disorienting—things jumping, flickering, desynchronizing in ways that don’t immediately point to the cause. The 10x slowdown trick was the breakthrough. Once I could see each frame, the fixes became obvious.</p> <p>If you want to dig deeper into the CSS features at play here, MDN has solid documentation on <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style"><code>@starting-style</code></a>, the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/overlay"><code>overlay</code></a> property, and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior"><code>allow-discrete</code></a>. But honestly, the best way to learn this stuff is to break it, slow it down, and watch what happens.</p>Stephen MargheimStylish &lt;dialog&gt;s2025-12-18T00:00:00+00:002025-12-18T00:00:00+00:00repo://posts.collection/_posts/2025-12-18-stylish-dialogs.md<p><a href="https://github.com/campsite/campsite">Campsite</a> has some of my favorite UI styling on the web. Naturally, I cracked open their source hoping to learn something. What I found: React components rendering <code>&lt;div&gt;</code>s inside <code>&lt;div&gt;</code>s, with piles of JavaScript doing what <code>&lt;dialog&gt;</code> does for free.</p> <p>So I borrowed their visual design and rebuilt it with semantic HTML and CSS using <a href="/2025/12/01/ui-affordances/">affordance classes</a>. I want to walk you through all of the choices I’ve made and how it all comes together.</p> <!--/summary--> <hr /> <h2 id="the-html">The HTML</h2> <p>Here’s the markup structure I use for a full-featured dialog:</p> <pre><code class="language-html">&lt;dialog id="example-dialog" class="ui/dialog" aria-labelledby="example-dialog-title" aria-describedby="example-dialog-desc" closedby="any"&gt; &lt;header&gt; &lt;hgroup&gt; &lt;h2 id="example-dialog-title"&gt;Basic Dialog&lt;/h2&gt; &lt;p id="example-dialog-desc"&gt;This is a basic dialog with header, content, and footer sections.&lt;/p&gt; &lt;/hgroup&gt; &lt;button type="button" class="ui/button/plain aspect-square" commandfor="example-dialog" command="close" aria-label="Close dialog"&gt;&amp;times;&lt;/button&gt; &lt;/header&gt; &lt;form method="POST" action="#"&gt; &lt;article&gt; &lt;p&gt; Dialog content goes here. This area can contain forms, text, images, or any other content. The native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element handles focus management and accessibility automatically. &lt;/p&gt; &lt;/article&gt; &lt;footer&gt; &lt;button class="ui/button/flat" type="submit" formmethod="dialog" formnovalidate value="cancel"&gt;Cancel&lt;/button&gt; &lt;button class="ui/button/primary" type="submit" autofocus&gt;Confirm&lt;/button&gt; &lt;/footer&gt; &lt;/form&gt; &lt;/dialog&gt; </code></pre> <h3 id="semantic-elements">Semantic Elements</h3> <p>Yes, I’m a semantic HTML nerd. <code>&lt;header&gt;</code>, <code>&lt;article&gt;</code>, <code>&lt;footer&gt;</code> instead of <code>&lt;div&gt;</code>s everywhere. The structure is obvious when you revisit the code six months later, and you can target these elements directly in CSS without inventing class names.</p> <h3 id="the-form-wrapper">The Form Wrapper</h3> <p>The body and footer are wrapped in a <code>&lt;form&gt;</code>. This might seem odd at first, but it unlocks key functionality. Dialogs often need to <em>do</em> something—create a resource, update settings, submit data. Wrapping in a form means your dialog is ready for that from the start. And for simple confirmations, <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#method"><code>method=dialog</code></a> on the form (or <a href="/tips/2025/12/08/dialog-cancel-buttons-with-formmethod/"><code>formmethod=dialog</code></a> on a button) closes the dialog without any network request.</p> <h3 id="two-close-mechanisms">Two Close Mechanisms</h3> <p>The header’s × button uses <a href="/tips/2025/12/09/dialog-close-button-with-command/"><code>command=close</code></a> because it’s outside the form. The footer’s Cancel button uses <code>formmethod=dialog</code> because it’s inside—a submit that closes without hitting the network.</p> <h3 id="focus-handling">Focus Handling</h3> <p>The confirm button has <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus"><code>autofocus</code></a>. When the dialog opens, focus moves there immediately—keyboard users land on the primary action, one Enter key away.</p> <h3 id="light-dismiss">Light Dismiss</h3> <p>The <a href="/tips/2025/12/10/dialog-light-dismiss-with-closedby/"><code>closedby=any</code></a> attribute enables “light dismiss”—clicking the backdrop closes the dialog. Combined with the browser’s built-in Escape key handling, users have multiple intuitive ways to close. No JavaScript event listeners required.</p> <h3 id="accessibility">Accessibility</h3> <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby"><code>aria-labelledby</code></a> and <a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby"><code>aria-describedby</code></a> attributes connect the dialog to its heading and description. Screen readers announce both immediately when the dialog opens, giving users full context before they need to act.</p> <p>For confirmation dialogs specifically, add <a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alertdialog_role"><code>role=alertdialog</code></a>. This signals that the dialog communicates an important message requiring a user response—distinct from a generic dialog that might just display information or offer a form. The browser and assistive technologies treat alert dialogs with appropriate urgency.</p> <pre><code class="language-html">&lt;dialog id="confirm-delete" role="alertdialog" aria-labelledby="confirm-title" aria-describedby="confirm-desc"&gt; &lt;!-- ... --&gt; &lt;/dialog&gt; </code></pre> <hr /> <h2 id="the-css-architecture">The CSS Architecture</h2> <p>The styles use Tailwind v4’s <code>@utility</code> directive to create tree-shakeable, autocomplete-friendly utility classes. Here’s the structure:</p> <pre><code class="language-css">@import "tailwindcss"; @theme { --shadow-dialog: 0px 0px 3.5px rgba(0, 0, 0, 0.04), 0px 0px 10px rgba(0, 0, 0, 0.04), 0px 0px 24px rgba(0, 0, 0, 0.05), 0px 0px 80px rgba(0, 0, 0, 0.08); --shadow-dialog-dark: inset 0 0.5px 0 rgb(255 255 255 / 0.08), inset 0 0 1px rgb(255 255 255 / 0.24), 0 0 0 0.5px rgb(0 0 0 / 1), 0px 0px 4px rgba(0, 0, 0, 0.08), 0px 0px 10px rgba(0, 0, 0, 0.12), 0px 0px 24px rgba(0, 0, 0, 0.16), 0px 0px 80px rgba(0, 0, 0, 0.2); } </code></pre> <p>I borrowed these layered shadows directly from Campsite. The multiple shadows at different blur radii create a more natural, ambient lighting effect—the kind that makes people think you hired a designer. In dark mode, inset shadows add an inner glow that gives the panel depth.</p> <hr /> <h2 id="the-base-dialog-utility">The Base Dialog Utility</h2> <pre><code class="language-css">@utility ui/dialog { :where(&amp;) { @apply rounded-lg border-none bg-white p-0 text-zinc-900 shadow-dialog; @apply isolate flex w-full flex-col; @apply max-w-[calc(100vw-32px)] min-w-sm; @apply pointer-events-none invisible; @apply m-auto; @apply max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)]; @variant sm { @apply max-w-md; } @variant focus { @apply outline-0; } @variant open { @apply pointer-events-auto visible; } @variant dark { @apply bg-zinc-900 text-zinc-50 shadow-dialog-dark; } } } </code></pre> <h3 id="the-visibility-problem">The Visibility Problem</h3> <p>A <code>&lt;dialog&gt;</code> with <code>display: flex</code> stays visible even when closed—the browser’s default <code>display: none</code> gets overridden. The fix: <code>pointer-events-none</code> and <code>invisible</code>. The dialog stays in the DOM but users can’t see or interact with it. When <code>[open]</code> applies, we flip both back. This also prevents a flash of dialog content on page load.</p> <h3 id="sizing-constraints">Sizing Constraints</h3> <p>The <code>max-w-[calc(100vw-32px)]</code> ensures the dialog never touches the screen edges—always 16px of breathing room on each side. The <code>min-w-sm</code> (24rem) prevents the dialog from becoming uncomfortably narrow on larger screens.</p> <p>For height, <code>max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)]</code> does more work. The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/length#dynamic_viewport_units"><code>dvh</code> unit</a> (dynamic viewport height) accounts for mobile browser chrome that appears and disappears. The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/env"><code>env(safe-area-inset-*)</code></a> functions respect the notch and home indicator on modern phones. Together, they ensure the dialog fits the <em>actual</em> available space, not just the theoretical viewport.</p> <h3 id="stacking-context">Stacking Context</h3> <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/isolation"><code>isolate</code></a> class creates a new stacking context. Any <code>z-index</code> values inside the dialog stay contained—dropdowns or tooltips won’t escape and interfere with elements outside.</p> <h3 id="why-focus-instead-of-focus-visible">Why <code>focus</code> Instead of <code>focus-visible</code></h3> <p>The focus variant removes the outline entirely. You might expect <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible"><code>:focus-visible</code></a> here, but <code>autofocus</code> on dialog buttons triggers <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:focus"><code>:focus</code></a>, not <code>:focus-visible</code>. If you only style <code>focus-visible</code>, autofocused elements remain unstyled.</p> <hr /> <h2 id="slot-classes-and-semantic-selectors">Slot Classes and Semantic Selectors</h2> <p>Now for a part I’m quite pleased with. We define independent “slot” utilities that can apply to any element:</p> <pre><code class="language-css">@utility dialog/header { :where(&amp;) { @apply relative flex-none rounded-t-lg p-4 text-sm; &amp;:has(&gt; button[command="close"]) { @apply pr-12; } } } @utility dialog/title { :where(&amp;) { @apply m-0 flex-1 font-semibold; } } @utility dialog/description { :where(&amp;) { @apply m-0 mt-0.5 text-zinc-600; @variant dark { @apply text-zinc-300; } } } @utility dialog/content { :where(&amp;) { @apply flex flex-1 flex-col overflow-y-auto p-4 pt-0 text-sm; } } @utility dialog/footer { :where(&amp;) { @apply flex items-center rounded-b-lg border-t border-black/10 p-3; @variant dark { @apply border-white/12; } } } </code></pre> <p>Then the parent <code>ui/dialog</code> utility applies these to semantic elements automatically:</p> <pre><code class="language-css">@utility ui/dialog { /* ... base styles ... */ :where(&amp; &gt; header) { @apply dialog/header; } :where(&amp; header hgroup :is(h1, h2, h3, h4, h5, h6)) { @apply dialog/title; } :where(&amp; header hgroup p) { @apply dialog/description; } :where(&amp; form &gt; article) { @apply dialog/content; } :where(&amp; form &gt; footer) { @apply dialog/footer; } } </code></pre> <p>Write semantic HTML, get automatic styling. Or apply <code>dialog/content</code> directly to a <code>&lt;div&gt;</code> when your framework generates custom markup.</p> <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:where"><code>:where()</code></a> wrapper keeps specificity at zero. Without it, these nested selectors would have higher specificity than single utility classes, and you’d be fighting your own styles every time you needed to customize something.</p> <hr /> <h2 id="animations">Animations</h2> <p>Most dialog implementations just fade in and out. That’s fine, but we can do better.</p> <pre><code class="language-css">@keyframes dialog-slide-up-scale-fade { from { opacity: 0; transform: translateY(20px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes dialog-scale-down-fade { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(0) scale(0.95); } } </code></pre> <h3 id="asymmetric-motion">Asymmetric Motion</h3> <p>Entry slides up and scales in. Exit just scales down and fades. Sliding down on exit felt wrong—it implies the dialog is <em>going</em> somewhere, but it’s not. It’s disappearing. Scale-down-and-fade says “dismissed” without false movement.</p> <h3 id="timing-differences">Timing Differences</h3> <pre><code class="language-css">@utility ui/dialog { --dialog-entry-duration: 0.2s; --dialog-exit-duration: calc(var(--dialog-entry-duration) * 0.75); --backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.2); --backdrop-exit-duration: calc(var(--dialog-exit-duration) * 0.75); } </code></pre> <p>Exit animations run at 75% the duration of entry. Entrances should feel intentional; exits should get out of the way.</p> <p>The backdrop animates even faster. On entry, it appears almost instantly (20% of dialog duration), then the dialog follows—the dialog emerges from the dimmed background rather than appearing on top of it. On exit, the backdrop fades before the dialog finishes so you never see the dialog floating against a fully-bright background.</p> <h3 id="the-technical-details">The Technical Details</h3> <pre><code class="language-css">@utility ui/dialog { animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards; transition: overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete, display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete; @variant open { animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards; @starting-style { animation: none; } } } </code></pre> <p>The <a href="/tips/2025/12/13/dialog-exit-animations-with-allow-discrete/"><code>allow-discrete</code></a> keyword on <code>display</code> and <code>overlay</code> is essential. These are discrete properties—they can’t interpolate between values. The keyword tells the browser to keep the element visible during the exit animation, only flipping to <code>display: none</code> after the animation completes. Without it, your exit animation just… doesn’t happen. The dialog vanishes instantly.</p> <p>The <a href="/tips/2025/12/12/dialog-enter-animations-with-starting-style/"><code>@starting-style</code></a> rule defines where the animation begins. Without it, the browser renders the dialog immediately in its final state. Same problem, opposite direction—no entry animation.</p> <hr /> <h2 id="button-styles">Button Styles</h2> <p>The demo includes button styles, but those deserve their own post. Coming soon.</p> <hr /> <h2 id="browser-support">Browser Support</h2> <table> <thead> <tr> <th>Feature</th> <th>Chrome</th> <th>Safari</th> <th>Firefox</th> </tr> </thead> <tbody> <tr> <td><code>command</code>/<code>commandfor</code></td> <td>135+</td> <td>26.2+</td> <td>144+</td> </tr> <tr> <td><code>@starting-style</code></td> <td>117+</td> <td>17.5+</td> <td>129+</td> </tr> <tr> <td><code>closedby</code></td> <td>134+</td> <td>Not yet</td> <td>141+</td> </tr> <tr> <td><code>allow-discrete</code></td> <td>117+</td> <td>17.4+</td> <td>129+</td> </tr> </tbody> </table> <p>For production today, you can use polyfills if needed:</p> <ul> <li><a href="https://github.com/keithamus/invokers-polyfill"><code>invokers-polyfill</code></a> for command/commandfor</li> <li><a href="https://github.com/nicjansma/dialog-closedby-polyfill"><code>dialog-closedby-polyfill</code></a> for closedby</li> </ul> <hr /> <h2 id="interactive-demo">Interactive Demo</h2> <p>Here’s a working demo. Try opening it, then close it different ways: click the × button, click Cancel, click Confirm, press Escape, or click the backdrop.</p> <style> @keyframes dialog-slide-up-scale-fade { from { opacity: 0; transform: translateY(20px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes dialog-scale-down-fade { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(0) scale(0.95); } } .ui\/dialog { pointer-events: none; visibility: hidden; isolation: isolate; margin: auto; max-height: calc(100dvh - env(safe-area-inset-bottom,0) - env(safe-area-inset-top,0) - 32px); display: flex; width: 100%; max-width: calc(100vw - 32px); min-width: 24rem; flex-direction: column; border-radius: 0.5rem; border-style: none; background-color: white; padding: 0; color: #18181b; box-shadow: 0px 0px 3.5px rgba(0, 0, 0, 0.04), 0px 0px 10px rgba(0, 0, 0, 0.04), 0px 0px 24px rgba(0, 0, 0, 0.05), 0px 0px 80px rgba(0, 0, 0, 0.08); --dialog-entry-duration: 0.2s; --dialog-exit-duration: calc(var(--dialog-entry-duration) * 0.75); --backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.2); --backdrop-exit-duration: calc(var(--dialog-exit-duration) * 0.75); --dialog-easing: cubic-bezier(0.16, 1, 0.3, 1); --backdrop-easing: ease-in-out; animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards; transition: overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete, display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete; } @media (width >= 40rem) { .ui\/dialog { max-width: 28rem; } } .ui\/dialog:focus { outline-width: 0px; } .ui\/dialog:is([open], :popover-open, :open) { pointer-events: auto; visibility: visible; animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards; transition: overlay var(--dialog-entry-duration) var(--dialog-easing) allow-discrete, display var(--dialog-entry-duration) var(--dialog-easing) allow-discrete; @starting-style { animation: none; } } .ui\/dialog::backdrop { background-color: rgb(0 0 0 / 0); transition: background-color var(--backdrop-exit-duration) var(--backdrop-easing), overlay var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete, display var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete; } .ui\/dialog[open]::backdrop { background-color: rgb(0 0 0 / 0.2); transition: background-color var(--backdrop-entry-duration) var(--backdrop-easing), overlay var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete, display var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete; @starting-style { background-color: rgb(0 0 0 / 0); } } /* Dark mode */ .ui\/dialog.dark { background-color: #18181b; color: #fafafa; box-shadow: inset 0 0.5px 0 rgb(255 255 255 / 0.08), inset 0 0 1px rgb(255 255 255 / 0.24), 0 0 0 0.5px rgb(0 0 0 / 1), 0px 0px 4px rgba(0, 0, 0, 0.08), 0px 0px 10px rgba(0, 0, 0, 0.12), 0px 0px 24px rgba(0, 0, 0, 0.16), 0px 0px 80px rgba(0, 0, 0, 0.2); } .ui\/dialog.dark::backdrop { background-color: rgb(0 0 0 / 0); } .ui\/dialog.dark[open]::backdrop { background-color: rgb(0 0 0 / 0.6); @starting-style { background-color: rgb(0 0 0 / 0); } } /* Dialog header */ .ui\/dialog > header { position: relative; flex: none; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; padding: 1rem; font-size: 0.875rem; } .ui\/dialog > header:has(> button[command="close"]) { padding-right: 3rem; } .ui\/dialog header hgroup { display: flex; flex-direction: column; gap: 0.125rem; } .ui\/dialog header hgroup :is(h1, h2, h3, h4, h5, h6) { margin: 0; flex: 1; font-weight: 600; } .ui\/dialog header hgroup p { margin: 0; margin-top: 0.125rem; color: #52525b; } .ui\/dialog.dark header hgroup p { color: #d4d4d8; } .ui\/dialog header > button[command="close"] { position: absolute; top: 0.75rem; right: 0.75rem; font-size: 1rem; } /* Dialog form/content/footer */ .ui\/dialog > form { display: flex; min-height: 0; flex: 1; flex-direction: column; } .ui\/dialog form > article { display: flex; flex: 1; flex-direction: column; overflow-y: auto; padding: 1rem; padding-top: 0; font-size: 0.875rem; } .ui\/dialog form > article p { margin: 0; } .ui\/dialog form > footer { margin: 0; display: flex; list-style-type: none; align-items: center; justify-content: flex-end; gap: 0.5rem; padding: 0; border-bottom-right-radius: 0.5rem; border-bottom-left-radius: 0.5rem; border-top: 1px solid rgb(0 0 0 / 0.1); padding: 0.75rem; } .ui\/dialog.dark form > footer { border-color: rgb(255 255 255 / 0.12); } /* Button styles */ .ui\/button { position: relative; display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; font-weight: 500; outline-width: 0px; user-select: none; height: 30px; border-radius: 0.375rem; padding-inline: 0.625rem; font-size: 0.875rem; cursor: pointer; border: 1px solid transparent; background-color: white; color: #18181b; box-shadow: 0px 1px 1px -1px rgb(0 0 0 / 0.08), 0px 2px 2px -1px rgb(0 0 0 / 0.08), 0px 0px 0px 1px rgb(0 0 0 / 0.06), inset 0px 1px 0px #fff, inset 0px 1px 2px 1px #fff, inset 0px 1px 2px rgb(0 0 0 / 0.06); } .ui\/button:hover { background-color: #f4f4f5; } .ui\/button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .ui\/button.dark { background-color: #3f3f46; color: #fafafa; box-shadow: 0px 0px 0px 0.5px rgb(0 0 0 / 0.4), 0px 1px 1px -1px rgb(0 0 0 / 0.12), 0px 2px 2px -1px rgb(0 0 0 / 0.12), inset 0px 0.5px 0px rgb(255 255 255 / 0.06), inset 0px 0px 1px 0px rgb(255 255 255 / 0.16), inset 0px -6px 12px -4px rgb(0 0 0 / 0.16); } .ui\/button.dark:hover { background-color: #52525b; } .ui\/button.dark:focus-visible { outline-color: white; } .ui\/button\/plain { position: relative; display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; font-weight: 500; outline-width: 0px; user-select: none; height: 30px; border-radius: 0.375rem; padding-inline: 0.625rem; font-size: 0.875rem; cursor: pointer; border: none; background-color: transparent; color: #18181b; box-shadow: none; } .ui\/button\/plain:hover { background-color: rgb(0 0 0 / 0.06); } .ui\/button\/plain:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .ui\/button\/plain.dark { color: #fafafa; } .ui\/button\/plain.dark:hover { background-color: rgb(255 255 255 / 0.08); } .ui\/button\/plain.dark:focus-visible { outline-color: white; } .ui\/button\/flat { position: relative; display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; font-weight: 500; outline-width: 0px; user-select: none; height: 30px; border-radius: 0.375rem; padding-inline: 0.625rem; font-size: 0.875rem; cursor: pointer; border: none; background-color: rgb(0 0 0 / 0.06); color: #18181b; box-shadow: none; } .ui\/button\/flat:hover { background-color: rgb(0 0 0 / 0.08); } .ui\/button\/flat:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .ui\/button\/flat.dark { background-color: rgb(255 255 255 / 0.08); color: #fafafa; } .ui\/button\/flat.dark:hover { background-color: rgb(255 255 255 / 0.1); } .ui\/button\/flat.dark:focus-visible { outline-color: white; } .ui\/button\/primary { position: relative; display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; font-weight: 500; outline-width: 0px; user-select: none; height: 30px; border-radius: 0.375rem; padding-inline: 0.625rem; font-size: 0.875rem; cursor: pointer; border: none; background-color: #27272a; color: #fafafa; box-shadow: none; } .ui\/button\/primary:hover { background-color: #3f3f46; } .ui\/button\/primary:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .ui\/button\/primary.dark { background-color: #f4f4f5; color: #18181b; } .ui\/button\/primary.dark:hover { background-color: #e4e4e7; } .ui\/button\/primary.dark:focus-visible { outline-color: white; } /* Event log */ .demo-log-area { overflow: hidden; border-top: 1px solid #3f3f46; } .demo-log-title { margin: 0; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 600; color: white; } #stylish-demo-log { display: block; max-height: 10rem; min-height: 5rem; overflow-y: auto; padding: 1rem; scrollbar-color: #3f3f46 transparent; scrollbar-width: thin; } #stylish-demo-log-list { margin: 0; list-style: none; padding: 0; } #stylish-demo-log-list li { display: flex; gap: 1rem; } #stylish-demo-log-list li + li { margin-top: 0.5rem; } #stylish-demo-log-list p { flex: 1; margin: 0; font-size: 0.75rem; line-height: 1.25rem; color: #a1a1aa; } #stylish-demo-log-list time { flex: none; font-size: 0.75rem; line-height: 1.25rem; color: #a1a1aa; } #stylish-demo-log-list .log-label { font-weight: 500; color: white; } #stylish-demo-log-list code { border-radius: 0.25rem; background-color: rgba(255, 255, 255, 0.1); padding: 0.125rem 0.375rem; color: #60a5fa; } </style> <script src="https://unpkg.com/invokers-polyfill" type="module"></script> <script src="https://unpkg.com/@fractaledmind/dialog-closedby-polyfill" type="module"></script> <div class="not-prose overflow-hidden rounded-lg bg-zinc-900"> <div class="flex justify-center p-6"> <button class="ui/button dark" commandfor="stylish-demo-dialog" command="show-modal"> Open Dialog </button> </div> <dialog id="stylish-demo-dialog" class="ui/dialog dark" closedby="any" aria-labelledby="stylish-demo-title" aria-describedby="stylish-demo-desc"> <header> <hgroup> <h2 id="stylish-demo-title">Basic Dialog</h2> <p id="stylish-demo-desc">This dialog demonstrates the styling patterns from this post.</p> </hgroup> <button type="button" class="ui/button/plain dark aspect-square" commandfor="stylish-demo-dialog" command="close" aria-label="Close dialog">&times;</button> </header> <form method="POST" action="#"> <article> <p> Dialog content goes here. Try closing this dialog different ways: click the × button, click Cancel, click Confirm, press Escape, or click the backdrop. </p> </article> <footer> <button class="ui/button/flat dark" type="submit" formmethod="dialog" formnovalidate="" value="cancel">Cancel</button> <button class="ui/button/primary dark" type="submit" formmethod="dialog" value="confirm" autofocus="">Confirm</button> </footer> </form> </dialog> <div class="demo-log-area"> <p class="demo-log-title">Event Log</p> <output id="stylish-demo-log" aria-live="polite"> <ul role="list" id="stylish-demo-log-list"></ul> </output> </div> </div> <script> (function() { const dialog = document.getElementById('stylish-demo-dialog'); const log = document.getElementById('stylish-demo-log-list'); if (!dialog || !log) return; function logEvent(message) { const time = new Date().toLocaleTimeString(); const entry = document.createElement('li'); entry.innerHTML = ` <p>${message}</p> <time>${time}</time> `; log.prepend(entry); } dialog.addEventListener('toggle', function(event) { if (event.newState === 'open') { dialog.returnValue = ''; logEvent('<span class="log-label">toggle</span> event — newState: <code>open</code>'); } else { logEvent('<span class="log-label">toggle</span> event — newState: <code>closed</code>'); } }); dialog.addEventListener('close', function() { const returnValue = dialog.returnValue || '(empty)'; logEvent('<span class="log-label">close</span> event — returnValue: <code>' + returnValue + '</code>'); }); dialog.addEventListener('cancel', function() { logEvent('<span class="log-label">cancel</span> event (Escape key)'); }); })(); </script> <p>Want to experiment? <a href="https://play.tailwindcss.com/0V4LTBpdHC">Explore the full demo on Tailwind Play</a> where you can tweak the styles and see how everything fits together.</p> <hr /> <h2 id="whats-next">What’s Next</h2> <p>Dialogs are just one piece. I’m building out a full set of <a href="/2025/12/01/ui-affordances/">affordance classes</a>—buttons, forms, menus, popovers, tabs, tables. Designer-quality styling, browser-native behavior. The kind of UI that makes people ask who you hired. So be on the lookout for more like this 👀.</p>Stephen MargheimConfirmation dialogs with zero JavaScript2025-12-16T00:00:00+00:002025-12-16T00:00:00+00:00repo://posts.collection/_posts/2025-12-16-turbo-confirm-dialogs-without-javascript.md<p>Turbo’s <code>data-turbo-confirm</code> attribute is convenient for quick confirmation dialogs, but the native <code>confirm()</code> prompt it triggers looks dated and out of place. If you want a styled confirmation dialog that matches your app’s design, the <a href="https://turbo.hotwired.dev/handbook/drive#requiring-confirmation-for-a-visit">traditional</a> <a href="https://gorails.com/episodes/custom-hotwire-turbo-confirm-modals">approach</a> <a href="https://www.beflagrant.com/blog/turbo-confirmation-bias-2024-01-10">recommends</a> a lot of JavaScript—a Stimulus controller to open and close the dialog, event listeners for keyboard handling, and coordination between the trigger and the modal.</p> <p>But, recent browser updates have changed the game. <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/command">Invoker Commands</a> landed in Chrome 131 and Safari 18.4, giving us declarative dialog control. Combined with <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style"><code>@starting-style</code></a> for animations, we can now build beautiful, animated confirmation dialogs without writing any JavaScript.</p> <!--/summary--> <table> <thead> <tr> <th>Feature</th> <th>How</th> </tr> </thead> <tbody> <tr> <td>Open dialog</td> <td><code>command="show-modal"</code> on a button</td> </tr> <tr> <td>Close dialog</td> <td><code>command="close"</code> on cancel button</td> </tr> <tr> <td>Escape key</td> <td>Built-in browser behavior</td> </tr> <tr> <td>Light dismiss</td> <td><code>closedby="any"</code> attribute</td> </tr> <tr> <td>Enter animation</td> <td><code>@starting-style</code> CSS rule</td> </tr> <tr> <td>Exit animation</td> <td><code>allow-discrete</code> on <code>display</code> transition</td> </tr> </tbody> </table> <hr /> <p>Let’s imagine we wanted a confirmation dialog for when a user decides to delete an item in our app. Here is how we could build such a dialog today using modern browser features with zero JavaScript:</p> <pre><code class="language-erb">&lt;button type="button" commandfor="delete-item-dialog" command="show-modal"&gt; Delete this item &lt;/button&gt; &lt;dialog id="delete-item-dialog" closedby="any" role="alertdialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc"&gt; &lt;header&gt; &lt;hgroup&gt; &lt;h3 id="dialog-title"&gt;Delete this item?&lt;/h3&gt; &lt;p id="dialog-desc"&gt;Are you sure you want to permanently delete this item?&lt;/p&gt; &lt;/hgroup&gt; &lt;/header&gt; &lt;footer&gt; &lt;button type="button" commandfor="delete-item-dialog" command="close"&gt; Cancel &lt;/button&gt; &lt;%= button_to item_path(item), method: :delete do %&gt; Delete item &lt;% end %&gt; &lt;/footer&gt; &lt;/dialog&gt; </code></pre> <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#command"><code>command</code></a> attribute tells the browser what action to perform, and <code>commandfor</code> specifies the target element by <code>id</code>. With <code>command="show-modal"</code>, clicking the button calls <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal"><code>showModal()</code></a> on the target dialog. The cancel button uses <code>command="close"</code> to call the dialog’s <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close"><code>close()</code> method</a>. Note the cancel button is <code>type="button"</code>, not <code>type="submit"</code>—we don’t want it participating in any form submission.</p> <p>Modal dialogs opened with <code>showModal()</code> automatically close on Escape. The browser handles it. Adding <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#closedby"><code>closedby="any"</code></a> enables “light dismiss”—clicking the backdrop closes the dialog too.</p> <p>The <code>aria-labelledby</code> and <code>aria-describedby</code> attributes connect the dialog to its heading and description. Screen reader users hear both the title and explanatory text announced immediately, giving them full context before making a decision. And <code>role="alertdialog"</code> signals the dialog is a confirmation window communicating an important message that requires a user response.</p> <p>So much functionality with nothing but declarative HTML! I love it.</p> <hr /> <h2 id="adding-animations-with-starting-style">Adding Animations with <code>@starting-style</code></h2> <p>For a polished feel, add smooth enter/exit transitions. With <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style"><code>@starting-style</code></a> and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior"><code>allow-discrete</code></a>, we can animate dialogs purely in CSS.</p> <p>The <code>@starting-style</code> rule defines the initial state when an element first appears. Without it, the browser renders the dialog immediately in its final state. With it, the browser starts from <code>opacity: 0; scale: 0.95</code> and transitions to <code>opacity: 1; scale: 1</code>, for example.</p> <p>For exit animations, we need <code>transition-behavior: allow-discrete</code> on <code>display</code> and <code>overlay</code>. Most CSS properties are continuous—opacity can be 0.5, colors can blend. But <code>display</code> is discrete: it’s either <code>none</code> or <code>block</code>, with no intermediate values. Historically, this meant <code>display</code> changes couldn’t animate.</p> <p>The <code>allow-discrete</code> keyword tells the browser to apply transition timing even for discrete properties. For closing animations, the browser keeps the element visible, runs the exit transition, then flips to <code>display: none</code> only after the transition completes. The <code>overlay</code> property works similarly—it controls whether the dialog stays in the <a href="https://developer.mozilla.org/en-US/docs/Glossary/Top_layer">top layer</a> during the transition.</p> <details> <summary>Example CSS</summary> <pre><code class="language-css">dialog { opacity: 1; scale: 1; transition: opacity 0.2s ease-out, scale 0.2s ease-out, overlay 0.2s ease-out allow-discrete, display 0.2s ease-out allow-discrete; @starting-style { opacity: 0; scale: 0.95; } } dialog:not([open]) { opacity: 0; scale: 0.95; } dialog::backdrop { background-color: rgb(0 0 0 / 0.5); transition: background-color 0.2s ease-out, overlay 0.2s ease-out allow-discrete, display 0.2s ease-out allow-discrete; @starting-style { background-color: rgb(0 0 0 / 0); } } dialog:not([open])::backdrop { background-color: rgb(0 0 0 / 0); } </code></pre> </details> <hr /> <h2 id="browser-support">Browser Support</h2> <table> <thead> <tr> <th>Feature</th> <th>Chrome</th> <th>Safari</th> <th>Firefox</th> <th style="text-align: center">Can I Use</th> </tr> </thead> <tbody> <tr> <td><code>command</code></td> <td>135+</td> <td>26.2+</td> <td>144+</td> <td style="text-align: center"><a href="https://caniuse.com/mdn-api_htmlbuttonelement_command">link</a></td> </tr> <tr> <td><code>commandfor</code></td> <td>135+</td> <td>26.2+</td> <td>144+</td> <td style="text-align: center"><a href="https://caniuse.com/mdn-html_elements_button_commandfor">link</a></td> </tr> <tr> <td><code>@starting-style</code></td> <td>117+</td> <td>17.5+</td> <td>129+</td> <td style="text-align: center"><a href="https://caniuse.com/mdn-css_at-rules_starting-style">link</a></td> </tr> <tr> <td><code>closedby</code></td> <td>134+</td> <td>Not yet</td> <td>141+</td> <td style="text-align: center"><a href="https://caniuse.com/mdn-html_elements_dialog_closedby">link</a></td> </tr> </tbody> </table> <p>Safari support for <code>closedby</code> is still pending. For production use today, add a polyfill: <a href="https://github.com/fractaledmind/dialog-closedby-polyfill"><code>dialog-closedby-polyfill</code></a></p> <p>If you need invoker command support for older browsers, there is also a polyfill for that: <a href="https://github.com/keithamus/invokers-polyfill"><code>invokers-polyfill</code></a></p> <p>Both polyfills are small and only run when native support is missing.</p> <hr /> <h2 id="integrating-with-turbos-confirm-system">Integrating with Turbo’s Confirm System</h2> <p>Now, what if you want to keep using Turbo’s <code>data-turbo-confirm</code> attribute while getting a styled native dialog?</p> <p>Turbo provides <a href="https://turbo.hotwired.dev/reference/drive#turbo.config.forms.confirm"><code>Turbo.config.forms.confirm</code></a> for exactly this. <a href="https://mhenrixon.com/articles/turbo-confirm">Mikael Henriksson has an excellent writeup</a> on this approach and Chris Oliver has <a href="https://gorails.com/episodes/custom-hotwire-turbo-confirm-modals">a GoRails video</a> as well.</p> <p>First, add a dialog template to your layout, which you can of course style however you’d like using whatever CSS tooling you have in your app:</p> <pre><code class="language-erb">&lt;%# app/views/layouts/application.html.erb %&gt; &lt;dialog id="turbo-confirm-dialog" closedby="any" aria-labelledby="turbo-confirm-title" aria-describedby="turbo-confirm-message"&gt; &lt;header&gt; &lt;hgroup&gt; &lt;h3 id="turbo-confirm-title"&gt;Confirm&lt;/h3&gt; &lt;p id="turbo-confirm-message"&gt;&lt;/p&gt; &lt;/hgroup&gt; &lt;/header&gt; &lt;footer&gt; &lt;button type="button" commandfor="turbo-confirm-dialog" command="close"&gt; Cancel &lt;/button&gt; &lt;form method="dialog"&gt; &lt;button type="submit" value="confirm"&gt; Confirm &lt;/button&gt; &lt;/form&gt; &lt;/footer&gt; &lt;/dialog&gt; </code></pre> <p>The Confirm button is <code>type="submit"</code> inside a <code>&lt;form method="dialog"&gt;</code>. When submitted, the browser closes the dialog and sets <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue"><code>returnValue</code></a> to the button’s <code>value</code> attribute. This is how we detect which button was pressed—no JavaScript event coordination needed.</p> <p>Then configure Turbo:</p> <pre><code class="language-javascript">const dialog = document.getElementById("turbo-confirm-dialog") const messageElement = document.getElementById("turbo-confirm-message") const confirmButton = dialog?.querySelector("button[value='confirm']") Turbo.config.forms.confirm = (message, element, submitter) =&gt; { // Fall back to native confirm if dialog isn't in the DOM if (!dialog) return Promise.resolve(confirm(message)) messageElement.textContent = message // Allow custom button text via data-turbo-confirm-button const buttonText = submitter?.dataset.turboConfirmButton || "Confirm" confirmButton.textContent = buttonText dialog.showModal() return new Promise((resolve) =&gt; { dialog.addEventListener("close", () =&gt; { resolve(dialog.returnValue === "confirm") }, { once: true }) }) } </code></pre> <p>The JavaScript does only three things:</p> <ol> <li>set the message text,</li> <li>customize the button text if provided, and</li> <li>open the dialog.</li> </ol> <p>Everything else—closing on button click, closing on Escape, closing on backdrop click, determining which button was pressed—is handled by the platform.</p> <p>The fallback to native <code>confirm()</code> ensures your app still works if the dialog element is missing (e.g., on a different layout or error page).</p> <p><code>Turbo.config.forms.confirm</code> expects a function returning a Promise that resolves to <code>true</code> (proceed) or <code>false</code> (cancel). The function receives three arguments: the confirmation message, the element with the <code>data-turbo-confirm</code> attribute, and the submitter element. We listen for the <code>close</code> event and check <code>returnValue</code>. Write this handler once, add one dialog to your layout, and every <code>data-turbo-confirm</code> in your app uses it.</p> <p>You can customize the confirm button text per-trigger using <code>data-turbo-confirm-button</code>:</p> <pre><code class="language-erb">&lt;%= button_to item_path(item), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this item?", turbo_confirm_button: "Delete item" } do %&gt; Delete &lt;% end %&gt; </code></pre> <p>This produces a more contextual confirmation dialog with “Delete item” instead of a generic “Confirm” button—better UX that makes the action clear.</p> <hr /> <h2 id="addendum-preventing-background-scroll">Addendum: Preventing Background Scroll</h2> <p>One common modal requirement is preventing page scroll while the dialog is open:</p> <pre><code class="language-css">body:has(dialog:modal) { overflow: hidden; } </code></pre> <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:modal"><code>:modal</code></a> pseudo-class matches dialogs opened with <code>showModal()</code>. Combined with <code>:has()</code>, this selector targets the body only when a modal dialog is open. When the dialog opens, scrolling stops. When it closes, scrolling resumes. The browser handles the coordination.</p> <hr /> <h2 id="appendix-interactive-demo">Appendix: Interactive Demo</h2> <p>Here’s a working demo showing how different close mechanisms work. Try clicking “Delete Item”, then close it different ways: click Cancel, click Delete, press Escape, or click the backdrop. Notice how <code>returnValue</code> is only <code>"confirm"</code> when you click the Delete button.</p> <style> /* Demo container */ .demo-container { overflow: hidden; border-radius: 0.375rem; background-color: #27272a; } .demo-trigger-area { padding: 1.5rem; } /* Trigger button */ .demo-trigger-btn { display: block; margin-left: auto; margin-right: auto; border-radius: 0.375rem; background-color: rgb(239 68 68 / 0.2); padding: 0.5rem 0.75rem; font-size: 0.875rem; font-weight: 600; color: #f87171; border: none; cursor: pointer; } .demo-trigger-btn:hover { background-color: rgb(239 68 68 / 0.3); } /* Dialog */ #demo-confirm-dialog { border-radius: 0.5rem; background-color: #27272a; padding: 1.5rem; text-align: left; box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); border: none; outline: 1px solid rgb(255 255 255 / 0.1); outline-offset: -1px; margin-top: 33dvh; width: 100%; max-width: 32rem; /* Animation */ opacity: 1; scale: 1; transition: opacity 0.2s ease-out, scale 0.2s ease-out, overlay 0.2s ease-out allow-discrete, display 0.2s ease-out allow-discrete; } #demo-confirm-dialog[open] { @starting-style { opacity: 0; scale: 0.95; } } #demo-confirm-dialog:not([open]) { opacity: 0; scale: 0.95; } #demo-confirm-dialog::backdrop { background-color: rgb(0 0 0 / 0.5); transition: background-color 0.2s ease-out, overlay 0.2s ease-out allow-discrete, display 0.2s ease-out allow-discrete; } #demo-confirm-dialog[open]::backdrop { @starting-style { background-color: rgb(0 0 0 / 0); } } #demo-confirm-dialog:not([open])::backdrop { background-color: rgb(0 0 0 / 0); } /* Dialog header */ #demo-confirm-dialog header hgroup { text-align: left; margin: 0; } #demo-dialog-title { font-size: 1rem; font-weight: 600; color: white; margin: 0; } #demo-dialog-desc { margin: 0.5rem 0 0 0; font-size: 0.875rem; color: #a1a1aa; } /* Dialog footer */ #demo-confirm-dialog footer { margin-top: 1rem; display: flex; flex-direction: row; gap: 0.75rem; } #demo-confirm-dialog footer form { margin: 0; } /* Cancel button */ .demo-cancel-btn { display: inline-flex; justify-content: center; border-radius: 0.375rem; background-color: rgb(255 255 255 / 0.1); padding: 0.5rem 0.75rem; font-size: 0.875rem; font-weight: 600; color: white; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05); border: none; cursor: pointer; } .demo-cancel-btn:hover { background-color: rgb(255 255 255 / 0.2); } /* Delete/Confirm button */ .demo-confirm-btn { display: inline-flex; justify-content: center; border-radius: 0.375rem; background-color: #ef4444; padding: 0.5rem 0.75rem; font-size: 0.875rem; font-weight: 600; color: white; border: none; cursor: pointer; } .demo-confirm-btn:hover { background-color: #f87171; } /* Event log area */ .demo-log-area { margin-top: 1rem; overflow: hidden; border-top: 1px solid #3f3f46; } .demo-log-title { margin: 0; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 600; color: white; } #demo-log { display: block; max-height: 10rem; min-height: 5rem; overflow-y: auto; padding: 1rem; scrollbar-color: #3f3f46 transparent; scrollbar-width: thin; } #demo-log-list { margin: 0; list-style: none; padding: 0; } #demo-log-list li { position: relative; display: flex; gap: 1rem; } #demo-log-list li + li { margin-top: 0.5rem; } #demo-log-list p { flex: 1 1 auto; padding: 0.125rem 0; font-size: 0.75rem; line-height: 1.25rem; color: #a1a1aa; margin: 0; } #demo-log-list time { flex: none; padding: 0.125rem 0; font-size: 0.75rem; line-height: 1.25rem; color: #a1a1aa; } #demo-log-list .log-label { font-weight: 500; color: white; } #demo-log-list code { border-radius: 0.25rem; background-color: rgb(255 255 255 / 0.1); padding: 0.125rem 0.375rem; color: #60a5fa; } </style> <script src="https://unpkg.com/invokers-polyfill" type="module"></script> <script src="https://unpkg.com/@fractaledmind/dialog-closedby-polyfill" type="module"></script> <div class="demo-container not-prose"> <div class="demo-trigger-area"> <button class="demo-trigger-btn" commandfor="demo-confirm-dialog" command="show-modal"> Delete Item </button> <dialog id="demo-confirm-dialog" closedby="any" aria-labelledby="demo-dialog-title" aria-describedby="demo-dialog-desc"> <header> <hgroup> <h3 id="demo-dialog-title">Delete this item?</h3> <p id="demo-dialog-desc">This action cannot be undone.</p> </hgroup> </header> <footer> <button type="button" class="demo-cancel-btn" commandfor="demo-confirm-dialog" command="close" autofocus=""> Cancel </button> <form method="dialog"> <button type="submit" class="demo-confirm-btn" value="confirm"> Delete </button> </form> </footer> </dialog> </div> <div class="demo-log-area"> <p class="demo-log-title">Event Log</p> <output id="demo-log" aria-live="polite"> <ul role="list" id="demo-log-list"></ul> </output> </div> </div> <script> (function() { const dialog = document.getElementById('demo-confirm-dialog'); const log = document.getElementById('demo-log-list'); if (!dialog || !log) return; function logEvent(message) { const time = new Date().toLocaleTimeString(); const entry = document.createElement('li'); entry.innerHTML = ` <p>${message}</p> <time>${time}</time> `; log.prepend(entry); } dialog.addEventListener('toggle', function(event) { console.log(event) if (event.newState === 'open') { dialog.returnValue = ''; logEvent('<span class="log-label">toggle</span> event — newState: <code>open</code>'); } else { logEvent('<span class="log-label">toggle</span> event — newState: <code>closed</code>'); } }); dialog.addEventListener('close', function() { const returnValue = dialog.returnValue || '(empty)'; logEvent('<span class="log-label">close</span> event — returnValue: <code>' + returnValue + '</code>'); }); dialog.addEventListener('cancel', function() { logEvent('<span class="log-label">cancel</span> event (Escape key)'); }); })(); </script>Stephen MargheimAffordances: The Missing Layer in Frontend Architecture2025-12-01T00:00:00+00:002025-12-01T00:00:00+00:00repo://posts.collection/_posts/2025-12-01-ui-affordances.md<p>I was building a form with a file input. Nothing fancy—just a place for users to upload a document. I wanted the trigger to look like the other buttons on the page: the same subtle shadows, the same hover effects, the same spacing. I was using <a href="https://catalyst.tailwindui.com">Catalyst</a>, the component kit from Tailwind Labs, so I had a <code>&lt;Button&gt;</code> component with all those styles baked in.</p> <p>But I couldn’t use it.</p> <p>A file input needs a <code>&lt;label&gt;</code> as its clickable element—that’s how you style file inputs without fighting the browser’s native UI. But Catalyst’s <code>&lt;Button&gt;</code> component only renders as a <code>&lt;button&gt;</code> element or a <code>&lt;Link&gt;</code>. There’s no way to apply those styles to a <code>&lt;label&gt;</code>.</p> <p>Some component libraries offer escape hatches—props like <code>asChild</code> or <code>render</code> that let you swap out the underlying element. But these props don’t just pass through styles; they pass through the component’s <em>behavior</em> too. That’s fine when you want both. But when you just need the <em>look</em>—when you need an element to <em>appear</em> clickable while retaining its own native semantics—components leave you stuck.</p> <p>This isn’t a bug in Catalyst. It’s a structural limitation of components as an abstraction. <strong>Components are poor vehicles for purely visual styles.</strong> And once you see this, you start seeing it everywhere.</p> <hr /> <h2 id="three-things-one-name">Three Things, One Name</h2> <p>Consider the word “button.” In frontend development, it actually refers to three genuinely distinct things:</p> <ol> <li>The <code>&lt;button&gt;</code> <strong>element</strong> — native HTML semantics and behavior</li> <li>The <code>Button</code> <strong>component</strong> — your library’s encapsulation of structure, behavior, and possibly styles</li> <li>The <code>button</code> <strong>visual pattern</strong> — rounded corners, padding, solid background, hover states; what makes something <em>look</em> clickable</li> </ol> <p>When you need to make a <code>&lt;label&gt;</code> look like a <code>&lt;button&gt;</code>, you can’t reach for an element or a component.</p> <p>The same is true for text inputs. There’s the element, the component, and the visual pattern—the border, the focus ring, the placeholder styling—that makes something <em>look</em> like a place to type. You might need that pattern on a <code>&lt;textarea&gt;</code>, a <code>&lt;select&gt;</code>, or a custom autocomplete built on a different element entirely.</p> <p>These visual patterns have a name in design theory: <strong>affordances</strong>—visual signals that communicate how an element can be interacted with. The term comes from Don Norman’s <em>The Design of Everyday Things</em>, where he described how the shape of a door handle tells you whether to push or pull. In interfaces, affordances are what make a button look pressable, an input look typeable, a link look clickable.</p> <p>The standard frontend architecture today needs to add this as the fourth conceptual layer:</p> <table> <thead> <tr> <th>Layer</th> <th>What It Is</th> <th>Example</th> </tr> </thead> <tbody> <tr> <td><strong>Tokens</strong></td> <td>Atomic design values</td> <td><code>--color-indigo-600</code>, <code>--spacing-4</code></td> </tr> <tr> <td><strong>Utilities</strong></td> <td>Single-purpose classes</td> <td><code>p-4</code>, <code>text-red-500</code></td> </tr> <tr> <td><strong>Affordances</strong></td> <td>Element-independent visual patterns</td> <td><code>.ui-button</code>, <code>.ui-input</code>, <code>.ui-card</code></td> </tr> <tr> <td><strong>Components</strong></td> <td>Encapsulated structure + behavior</td> <td><code>&lt;Button&gt;</code>, <code>&lt;Dialog&gt;</code></td> </tr> </tbody> </table> <p>Most libraries today bury affordances inside components. That’s the friction I hit with that file input—and I suspect you’ve hit it too. Isolating and naming the affordance layer restores the needed flexibility.</p> <hr /> <h2 id="unnecessary-pain">Unnecessary Pain</h2> <p>I love utility classes. I advocate for Tailwind in every team I work with. But when you don’t have an affordance layer and your team enforces a utility-only approach, every element that needs to look interactive gets its own pile of the same classes:</p> <pre><code class="language-html">&lt;label class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"&gt; Upload file &lt;/label&gt; </code></pre> <p>And every other element that needs that same treatment—an <code>&lt;a&gt;</code> styled as a button, a <code>&lt;summary&gt;</code>, a <code>&lt;div role="button"&gt;</code>—gets its own copy of those same fourteen classes.</p> <p>This creates three concrete problems. First, there’s no single source of truth. When the design changes—when <code>blue-600</code> becomes <code>indigo-600</code>, or the border radius shifts from <code>rounded-md</code> to <code>rounded-lg</code>—you’re hunting through your codebase for every instance. Find-and-replace helps, but only if the classes appear in the same order, with the same formatting, across every file. They won’t.</p> <p>Second, the abstraction leaks into component APIs. Without an affordance layer, you end up passing styling props through components or accepting arbitrary <code>className</code> strings that get merged in unpredictable ways. The component boundary, which should encapsulate complexity, becomes a surface for styling conflicts.</p> <p>Third, consistency degrades over time. One developer uses <code>hover:bg-indigo-500</code>. Another uses <code>hover:bg-indigo-600</code>. A third forgets the focus styles entirely. No single element is <em>wrong</em>, but the product slowly becomes a patchwork. There’s no abstraction enforcing that “things which afford clicking should look like <em>this</em>.”</p> <p>Beyond the pain of such a utility-only approach, we also feel the need to isolate purely presentational styles from purely behavioral components. That’s why headless libraries—<a href="https://base-ui.com">Base UI</a>, <a href="https://headlessui.com">Headless UI</a>, <a href="https://www.radix-ui.com">Radix UI</a>—have swept through the frontend community like wildfire. We know behavior and styling need to be decoupled.</p> <p>Headless libraries solved half the problem. They gave us components that handle accessibility, keyboard navigation, focus management—all the behavioral complexity—without dictating appearance.</p> <p>But they left the styling half unsolved. Without a clear concept for that layer, most developers just inline utilities everywhere. And that brings us right back to the problems above: no single source of truth, leaky component APIs, consistency drift.</p> <p>Affordances solve this other half.</p> <hr /> <h2 id="we-tried-that-it-didnt-work">“We Tried That. It Didn’t Work.”</h2> <p>I hear the objection already: “Isn’t this just… semantic CSS classes? We tried that.”</p> <p>You’re right that we tried it. But “it didn’t work” deserves unpacking.</p> <p>The problem with traditional semantic classes like <code>.btn</code> wasn’t the concept—it was the execution. In a utility-first world, semantic classes create specificity conflicts. If you write <code>.btn { background: blue; }</code>, that rule has a specificity of <code>0,1,0</code>. When you try to customize with a utility like <code>bg-red-500</code>, the utility <em>also</em> has specificity <code>0,1,0</code>. Order-dependent chaos ensues. You end up fighting the cascade instead of working with it.</p> <p>Many developers who were burned by Bootstrap’s specificity nightmares found Tailwind and subsequently internalized “never write semantic classes” as a hard rule. That lesson may have been correct <em>at the time</em>, but the platform has evolved.</p> <p>CSS now has <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@layer">cascade layers</a>—a feature designed precisely for this problem. Layers let you define explicit priority ordering for groups of styles, independent of specificity or source order. Styles in later layers always beat styles in earlier layers, regardless of selector specificity.</p> <p>This means you can put affordances in a layer that sits <em>below</em> utilities:</p> <pre><code class="language-css">@layer affordances, utilities; @layer affordances { .ui-button { display: inline-flex; align-items: center; padding: var(--spacing-2) var(--spacing-4); border-radius: var(--rounded-md); background-color: var(--color-indigo-600); color: var(--color-white); font-size: var(--text-sm); font-weight: var(--font-semibold); box-shadow: var(--shadow-sm); &amp;:hover { background-color: var(--color-indigo-500); } &amp;:focus-visible { outline: 2px solid var(--color-indigo-600); outline-offset: 2px; } } } </code></pre> <p>Now any utility in the <code>utilities</code> layer will override <code>.ui-button</code>, no matter how specific the affordance selector is. Tailwind v4 already uses layers internally, so your utilities will naturally win.</p> <p>For extra safety—or if you’re working with CSS that doesn’t use layers—you can also wrap selectors in <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:where"><code>:where()</code> pseudo-class</a>, which contributes zero specificity:</p> <pre><code class="language-css">@layer affordances { :where(.ui-button) { /* ... */ } } </code></pre> <p>Now <code>.ui-button</code> has zero specificity <em>and</em> lives in a lower layer. Belt and suspenders.</p> <pre><code class="language-html">&lt;button class="ui-button bg-red-600 hover:bg-red-500"&gt;Delete&lt;/button&gt; </code></pre> <p>The <code>bg-red-600</code> utility wins. No <code>!important</code>, no specificity wars, no awkward workarounds. The affordance provides sensible defaults; utilities customize as needed.</p> <p class="notice"><strong>NOTE:</strong> I use the <code>ui-</code> prefix to distinguish affordance classes from old-school semantic classes. When you see <code>.btn</code>, you might reasonably wonder if it’s a Bootstrap remnant with unpredictable specificity. When you see <code>.ui-button</code>, the prefix signals intent: this is a low-specificity visual pattern designed to compose with utilities. Pick whatever convention works for your team: <code>af-</code>, <code>look-</code>, or just go unprefixed if you’re starting fresh—but having <em>a</em> convention helps communicate that these aren’t last decade’s semantic classes.</p> <p>This isn’t a return to 2015. It’s a genuinely new capability. Cascade layers and <code>:where()</code> achieved broad browser support in 2022, and we’re only now catching up to what they enable. The tooling is ready. It’s time for our concepts, terminology, and best practices to follow.</p> <hr /> <h2 id="in-practice">In Practice</h2> <p>Remember that file input from the opening? Here’s how it looks with an affordance layer:</p> <pre><code class="language-html">&lt;label for="document-upload" class="ui-button"&gt; Choose file &lt;/label&gt; &lt;input type="file" id="document-upload" class="sr-only" /&gt; </code></pre> <p>That’s it. The <code>&lt;label&gt;</code> gets the button affordance. The <code>&lt;input&gt;</code> is visually hidden but remains accessible. The <code>&lt;label&gt;</code> retains its native behavior—clicking it still triggers the file picker. No component gymnastics, no escape hatches, no fighting the framework.</p> <p>If Catalyst shipped a <code>.ui-button</code> affordance class alongside its <code>&lt;Button&gt;</code> component, I never would have been stuck. The component could still encapsulate behavior for the common case. But when I needed just the <em>look</em>—for a <code>&lt;label&gt;</code>, a <code>&lt;summary&gt;</code>, an <code>&lt;a&gt;</code>, whatever—I’d have a clean path forward.</p> <p>“What about primary, secondary, and destructive button variants?” I hear you ask. You have two options.</p> <p>The first is to define variant affordance classes:</p> <pre><code class="language-css">@layer affordances { :where(.ui-button-secondary) { background-color: var(--color-gray-100); color: var(--color-gray-900); &amp;:hover { background-color: var(--color-gray-200); } } :where(.ui-button-danger) { background-color: var(--color-red-600); &amp;:hover { background-color: var(--color-red-500); } } } </code></pre> <p>The second is to use a base affordance and compose with utilities:</p> <pre><code class="language-html">&lt;button class="ui-button bg-red-600 hover:bg-red-500"&gt;Delete&lt;/button&gt; </code></pre> <p>I prefer the composition approach for one-offs and the variant classes for patterns that repeat across your codebase. The zero-specificity foundation makes both work seamlessly.</p> <hr /> <h2 id="a-call-to-library-authors">A Call to Library Authors</h2> <p>If you maintain a component library, consider this: <strong>ship affordances alongside your components</strong>.</p> <p>Your <code>&lt;Button&gt;</code> component is valuable. It handles click events, loading states, disabled styling, maybe even analytics. Keep all of that.</p> <p>But also ship a <code>.ui-button</code> class (or whatever naming convention you prefer) that carries <em>just</em> the visual treatment. Let developers apply that class to any element they need. Use <code>@layer</code> and <code>:where()</code> to ensure it composes cleanly with utilities.</p> <p>The same goes for inputs, cards, badges, and every other visual pattern in your system. The component is the opinionated, full-featured path. The affordance is the escape hatch—the flexibility that lets developers solve problems you didn’t anticipate.</p> <p>This is the shift I’m advocating for: stop treating visual patterns as implementation details of components. Expose them as first-class primitives. Use <code>:where()</code> to make them composable with utilities. Let developers apply them to any element, for any reason, without gymnastics.</p> <hr /> <h2 id="the-way-forward">The Way Forward</h2> <p>The <code>&lt;button&gt;</code> element, the <code>Button</code> component, and the <code>.ui-button</code> affordance are three different things. Modern frontend architecture should treat them that way.</p> <p>We’ve spent years learning to separate concerns—content from presentation, structure from styling, behavior from appearance. Headless libraries gave us behavioral components without visual opinions. But we stopped halfway. We decoupled behavior from styling, then immediately coupled styling back to specific elements by inlining utilities everywhere.</p> <p>Affordances complete the separation. They give us reusable visual patterns that work with any element, compose cleanly with utilities, and provide a single source of truth for how interactive elements should look.</p> <p>With <code>@layer</code> and <code>:where()</code>, we finally have the technical foundation to make this work without specificity conflicts. The only thing missing is the shift in how we think about our CSS architecture.</p>Stephen MargheimCSS-only Star Rating Component with Half Steps2025-06-19T00:00:00+00:002025-06-19T00:00:00+00:00repo://posts.collection/_posts/2025-06-19-css-only-star-rating-component-with-half-steps.md<p>After some experimentation, research, and AI being stupid, I finally have a simple, clean implementation of a star rating component that uses only radio inputs and labels and allows for half steps. 50 lines of beautiful CSS. Let’s break it down piece by piece.</p> <!--/summary--> <hr /> <p>Before I dove into the code, I did some research on how others had tackled this problem with pure CSS. I found <a href="https://codepen.io/anefzaoui/pen/NWPZzMa?editors=1100">two</a> <a href="https://iamkate.com/code/star-rating-widget/">implementations</a> that I liked. Both used simple radio inputs and labels, which is essential to the solution I want. But, both had some limitations that I didn’t like. <a href="https://iamkate.com/code/star-rating-widget/">One</a> didn’t support half steps, while the <a href="https://codepen.io/anefzaoui/pen/NWPZzMa?editors=1100">other</a> relied on the FontAwesome font. I want to use simple background image SVGs and radio inputs. So, after digesting some details from those solutions, I turned to writing my own.</p> <p><img src="/images/star-rating.gif" alt="A user interacting with a star rating component" style="margin-inline: auto;" /></p> <p>There are a handful of essential details. Let’s walk through them one by one.</p> <p>The first detail concerns the HTML structure. Since we only want to use vanilla CSS, we have some constraints around the features we have access to. We can use the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Subsequent-sibling_combinator">subsequent sibling selector</a> to select elements <em>after</em> a hovered one. But, in a star rating component, we need to highlight the stars <em>before</em> the hovered one, showing which stars will be selected if the currently hovered radio is checked. In order to achieve this behavior, our HTML structure will put the radios in reverse order, from 5 stars to 0.5 stars:</p> <pre><code class="language-html">&lt;fieldset class="star-rating"&gt; &lt;input type="radio" id="rating10" name="rating" value="10" /&gt; &lt;label for="rating10" title="5 stars" aria-label="5 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating9" name="rating" value="9" /&gt; &lt;label for="rating9" title="4 1/2 stars" aria-label="4 1/2 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating8" name="rating" value="8" /&gt; &lt;label for="rating8" title="4 stars" aria-label="4 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating7" name="rating" value="7" /&gt; &lt;label for="rating7" title="3 1/2 stars" aria-label="3 1/2 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating6" name="rating" value="6" /&gt; &lt;label for="rating6" title="3 stars" aria-label="3 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating5" name="rating" value="5" /&gt; &lt;label for="rating5" title="2 1/2 stars" aria-label="2 1/2 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating4" name="rating" value="4" /&gt; &lt;label for="rating4" title="2 stars" aria-label="2 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating3" name="rating" value="3" /&gt; &lt;label for="rating3" title="1 1/2 stars" aria-label="1 1/2 stars"&gt;&lt;/label&gt; &lt;input type="radio" id="rating2" name="rating" value="2" /&gt; &lt;label for="rating2" title="1 star" aria-label="1 star"&gt;&lt;/label&gt; &lt;input type="radio" id="rating1" name="rating" value="1" /&gt; &lt;label for="rating1" title="1/2 star" aria-label="1/2 star"&gt;&lt;/label&gt; &lt;/fieldset&gt; </code></pre> <p>This allows us to easily highlight all radios <em>beneath</em> the currently hovered/checked one:</p> <pre><code class="language-css">/* color current and previous stars on checked */ input:checked ~ label, /* color previous stars on hover */ label:hover, label:hover ~ label { background-color: goldenrod; } </code></pre> <p>If <code>rating9</code> were hovered or checked, all radios subsequent in the DOM (so, <code>rating8</code> and below) would be highlighted in <strong>goldenrod</strong>.</p> <p>But, by having the radios in DOM order from highest to lowest rating, the component would render backwards relative to the expected order. Users expect to have the 0.5 rating first, followed by the 1 star rating, then the 1.5 star rating, and so on. So, we need the rendered order to be reversed from the DOM order. Luckily, CSS provides <code>flex</code> layouts which make it easy to reverse the order of elements via <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction"><code>flex-direction</code></a>:</p> <pre><code class="language-css">.star-rating { display: inline-flex; flex-direction: row-reverse; justify-content: flex-end; } </code></pre> <p>By making the <code>.rate</code> container a flex container with <code>flex-direction: row-reverse</code>, we can reverse the order of the stars in the UI while maintaining the needed DOM order.</p> <p>This technique of having the DOM order be optimized for CSS selectors, while the UI order is optimized for usage is a powerful tool to have in your CSS toolbelt.</p> <p>The next essential detail is rendering the stars. Supporting half steps makes this component notably more complicated. In order to keep things straightforward, I took the <a href="https://fontawesome.com/icons/star-half?f=classic&amp;s=solid">FontAwesome half-star icon</a> and manually created SVGs for both a left- and right-handed half star, with no padding:</p> <pre><code class="language-html">&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt; &lt;path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/&gt; &lt;/svg&gt; </code></pre> <pre><code class="language-html">&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt; &lt;path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/&gt; &lt;/svg&gt; </code></pre> <p>I then convert these SVGs into <code>backgroung</code> <code>url</code>s, using the appropriate image for each label:</p> <pre><code class="language-css">/* full star steps; right-handed half star */ label:nth-of-type(odd) { background: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; } /* half star steps; left-handed half star */ label:nth-of-type(even) { background: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; } </code></pre> <p>Since we will render stars for the <code>label</code>s, we can simply visually hide the <code>input</code>s:</p> <pre><code class="language-css">input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } </code></pre> <p>Then, we style the <code>label</code>s to properly render the background SVG images:</p> <pre><code class="language-css">label { display: block; height: 2rem; width: 1rem; } </code></pre> <p>The key detail is to have the <code>width</code> be <em>half</em> the size of the <code>height</code>.</p> <p>With the <code>input</code> elements visually hidden (but still accessible in the DOM for screen readers) and the <code>label</code> elements rendering the half star SVG images, we have the foundation for our component:</p> <p><img src="/images/basic-star-rating.png" alt="5 star icons lined up one next to the other" style="margin-inline: auto;" /></p> <p>The next detail is to highlight the star segments on hover and selection.</p> <p>This is unfortunately not possible with CSS <code>background</code> property using an embedded SVG <code>url</code>. You cannot dynamically change the <code>fill</code> color of an SVG background image using CSS. Luckily, we can take advantage of <a href="https://pqina.nl/blog/set-svg-background-image-fill-color/">this technique</a> and use the <code>mask</code> property instead of <code>background</code>, which allows a <code>background-color</code> to bleed through. So, we update our <code>label</code> CSS like so:</p> <pre><code class="language-css">label { display: block; height: 2rem; width: 1rem; background-color: currentColor; } /* full star steps; right-handed half star */ label:nth-of-type(odd) { mask: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; } /* half star steps; left-handed half star */ label:nth-of-type(even) { mask: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; } </code></pre> <p>This now permits us to highlight the star segments on hover using a simple <code>background-color</code> change:</p> <pre><code class="language-css">/* color current and previous stars on checked */ input:checked ~ label, /* color previous stars on hover */ label:hover, label:hover ~ label { background-color: goldenrod; } </code></pre> <p>Likewise, we can style the appropriate star segments based on <code>checked</code> state similarly:</p> <pre><code class="language-css">/* highlight current and previous stars */ input:checked + label:hover, input:checked ~ label:hover, /* highlight previous selected stars for new rating */ input:checked ~ label:hover ~ label, /* highlight previous selected stars */ label:hover ~ input:checked ~ label { background-color: gold; } </code></pre> <p>This makes our component beautifully interactive:</p> <p><img src="/images/colored-star-rating.gif" alt="A user interacting with a star rating component" style="margin-inline: auto;" /></p> <p>The final detail is adding a bit of spacing between the stars, but in such a way that the hover interaction is smooth and natural. My initial idea was to add a bit of <code>margin</code> to the full star step elements:</p> <pre><code class="language-css">/* full star steps; right-handed half star */ label:nth-of-type(odd) { mask: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; margin-inline-end: 0.25em; } </code></pre> <p>But, this created a fragmented hover interaction:</p> <p><img src="/images/rough-star-rating.gif" alt="A user interacting with a star rating component" style="margin-inline: auto;" /></p> <p>Whenever the mouse is <em>between</em> stars, no stars are highlighted at all. This creates a fractured user experience, where stars are highlighted and unhighlighted in a disjointed manner. What we need is a way to ensure that there is a visual gap between stars, but when the mouse is in that visual gap, it is still <em>technically</em> hovering over a star segment. We can accomplish this by adding expanding the width of the <code>label</code> elements for the outer, half step star segments, while keeping the image width the smaller fixed width:</p> <pre><code class="language-css">label:nth-of-type(odd) { mask: url('data:image/svg+xml,&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"&gt;&lt;path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/&gt;&lt;/svg&gt;') no-repeat; width: 1.25rem; mask-size: 1rem; } </code></pre> <p>The key detail is that the <code>mask-size</code> equals the default <code>width</code> of the stars and the <code>width</code> for these segments includes some visual padding. Here, I have done it manually, but we can also use CSS properties and the <code>calc()</code> function:</p> <pre><code class="language-css">/* settable properties */ --star-size: 2rem; --star-gap: 0.25rem; /* computed properties */ --star-height: var(--star-size); --star-width: calc(var(--star-size) / 2); --star-width-plus-gap: calc(var(--star-width) + var(--star-gap)); </code></pre> <p>Either way, by expanding the width of the right-hand star segments, we create a gap between the stars that still triggers the hover styles on the visually preceding star segment. This ensures that the hover interaction is seamless and continuous, providing a smoother user experience.</p> <p>The only other final detail is to remove the final margin on the right-hand side of the component:</p> <pre><code class="language-css">label:first-of-type { width: 1rem; /* or width: var(--star-width) */ } </code></pre> <p>We use the <code>first-of-type</code> selector because, remember, the far right star segment is actually the <em>first</em> star segment in the DOM order. Now, the <code>star-rating</code> component is exactly the width of the stars. If you are using the <code>fieldset</code> element as the wrapping element, you may want to remove the border as well:</p> <pre><code class="language-css">fieldset { border: none; } </code></pre> <p>But, with all of that, our vanilla CSS star rating component is now complete. It is fully functional, responsive, and accessible. It also provides a smooth user experience with no visual gaps between stars:</p> <p><img src="/images/star-rating.gif" alt="A user interacting with a star rating component" style="margin-inline: auto;" /></p> <p>If you want to see the full code, check out the <a href="https://play.tailwindcss.com/SMXsGVXaHO">playground</a>. And, if you’ve enjoyed following along, you would likely enjoy following my Twitter account: <a href="https://x.com/intent/follow?screen_name=fractaledmind">@fractaledmind</a>.</p>Stephen MargheimAlways use flex-wrap: wrap on flex containers2024-12-12T00:00:00+00:002024-12-12T00:00:00+00:00repo://posts.collection/_posts/2024-12-12-responsive-design-tip-flex-wrap.md<p>I spent a bit of time this morning make some <a href="/2024/12/11/sqlite-directory-updates/">improvements</a> to <a href="https://sqlite.directory">sqlite.directory</a>, and I found myself needing to make a number of fixes around the mobile responsiveness of the UI. So, I thought I would take a moment to catalog a few common patterns and tips I have found useful when working with responsive design. This first tip was one that I found myself using quite a bit today.</p> <!--/summary--> <hr /> <p>I use flex containers a ton; I love them. But, I find myself overlooking the same responsive detail time and time again—I don’t properly handle wrapping. Let’s take a real example from the <a href="https://sqlite.directory">sqlite.directory</a> header:</p> <p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="raBMxRJ" data-user="smargh" style="width: 300px; height: 300px; 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/smargh/pen/raBMxRJ"> responsive design tip: flex-wrap (before)</a> by Stephen Margheim (<a href="https://codepen.io/smargh">@smargh</a>) on <a href="https://codepen.io">CodePen</a>.</span> </p> <p>As you can see, on a smaller screen, the website title/logo and the user info are smashed together. Just ugly. I fixed this issue in <a href="https://github.com/fractaledmind/sqlite.directory/commit/07a5a3d12ee4fc8945c120c0cff86ce5663282c3">this commit</a> where I made these two simple changes:</p> <pre><code class="language-html">&lt;header class="w-full max-w-6xl mx-auto py-4 mb-4 text-lg flex justify-between items-center border-b"&gt; &lt;!-- [tl! remove:1] --&gt; &lt;h2 class="flex items-center gap-2"&gt; &lt;header class="w-full max-w-6xl mx-auto py-4 mb-4 text-lg flex justify-between items-center flex-wrap gap-y-2 border-b"&gt; &lt;!-- [tl! add:1] --&gt; &lt;h2 class="flex items-center gap-2 whitespace-nowrap"&gt; &lt;%= link_to root_path, class: "group" do %&gt; &lt;%= image_tag "/icon.svg", class: "inline-block" %&gt; &lt;code class=""&gt; &lt;span class="text-blue-500 group-hover:underline decoration-blue-500"&gt;sqlite&lt;/span&gt; &lt;span class="inline-block group-hover:animate-bouncing -mx-3"&gt;.&lt;/span&gt; &lt;span class="text-black group-hover:underline decoration-black"&gt;directory&lt;/span&gt; &lt;/code&gt; &lt;% end %&gt; &lt;/h2&gt; &lt;div class="inline-flex items-center gap-2"&gt; &lt;!-- [tl! remove] --&gt; &lt;div class="ml-auto inline-flex items-center gap-2"&gt; &lt;!-- [tl! add] --&gt; </code></pre> <p>The result speaks for itself:</p> <p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="raBMxoJ" data-user="smargh" style="width: 300px; height: 300px; 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/smargh/pen/raBMxoJ"> responsive design tip: flex-wrap (after)</a> by Stephen Margheim (<a href="https://codepen.io/smargh">@smargh</a>) on <a href="https://codepen.io">CodePen</a>.</span> </p> <p>Let’s break down the changes precisely:</p> <ol> <li>Add <code>flex-wrap gap-y-2</code> to the <code>&lt;header&gt;</code> flex container</li> <li>Add <code>whitespace-nowrap</code> to the <code>&lt;h2&gt;</code> containing the website title/logo</li> <li>Add <code>ml-auto</code> to the <code>&lt;div&gt;</code> containing the user info</li> </ol> <p>The first change is the most important. By adding <code>flex-wrap</code> to the flex container, we allow the children to wrap to the next line when the container is too small. This is a simple change that can make a big difference in the responsiveness of your UI.</p> <p>Next, we simply manage the wrapping more elegantly. We tell the website title/logo to not wrap its text with <code>whitespace-nowrap</code>, and we tell the user info to always sit to the right with <code>ml-auto</code>.</p> <p>All combined, we get a much more pleasant mobile experience. I hope this tip helps you as much as it has helped me. Happy coding!</p>Stephen Margheimsqlite.directory updates2024-12-11T00:00:00+00:002024-12-11T00:00:00+00:00repo://posts.collection/_posts/2024-12-11-sqlite-directory-updates.md<p>After the initial launch of <a href="https://sqlite.directory">sqlite.directory</a>, I have been working on a few updates to the site. Here are some of the updates:</p> <ul> <li>Show count of apps (<a href="https://github.com/fractaledmind/sqlite.directory/commit/59ba725d3510306f1541b9ed7a1ab213120cc26b"><code>59ba725</code></a>)</li> <li>Show favicon of app (<a href="https://github.com/fractaledmind/sqlite.directory/commit/d89dfbeb510c03d71df96b8610d87309ef9bf91a"><code>d89dfbe</code></a>)</li> <li>Randomize the order of app entries (<a href="https://github.com/fractaledmind/sqlite.directory/commit/d703bf99a63996af894a3f34bf30ae73b5d725d6"><code>d703bf9</code></a>)</li> <li>Validate that even the optional Repository URL is a valid URL (<a href="https://github.com/fractaledmind/sqlite.directory/commit/15ad91100b4a7bb9143ef46adb734dc67e576a2e"><code>15ad911</code></a></li> <li>Make the application more responsive (<a href="https://github.com/fractaledmind/sqlite.directory/commit/07a5a3d12ee4fc8945c120c0cff86ce5663282c3"><code>07a5a3d</code></a>, <a href="https://github.com/fractaledmind/sqlite.directory/commit/ea01025e544bd1c8afb152181f33b1c1cafb2cc5"><code>ea01025</code></a>, <a href="https://github.com/fractaledmind/sqlite.directory/commit/e370f9dd4436de86713828be68db34a61951474a"><code>e370f9d</code></a>, <a href="https://github.com/fractaledmind/sqlite.directory/commit/daed3ab7e28f496eeae9e8c512761fc38b35db6b"><code>daed3ab</code></a>, <a href="https://github.com/fractaledmind/sqlite.directory/commit/5568fdd41c8605aa10434fbecd46ac6d7363418f"><code>5568fdd</code></a>, <a href="https://github.com/fractaledmind/sqlite.directory/commit/93c5c4320e0128fa1fa02a9338e0869d4bcd32ed"><code>93c5c43</code></a>, and <a href="https://github.com/fractaledmind/sqlite.directory/commit/3b8cf5efabcf5ff9d5e11dcf370756d507092167"><code>3b8cf5e</code></a>)</li> <li>Update the PWA manifest to improve colors, description, and quick actions (<a href="https://github.com/fractaledmind/sqlite.directory/commit/31422f610915544d1f6195a1e98515647c587727"><code>31422f6</code></a>)</li> </ul> <p>As of writing this (December 11, 2024 at 1:30 PM CET) there are <strong>17</strong> applications listed on the site. I am excited to see the site grow and to see more applications listed on the site. If you have a project that uses SQLite, please consider adding it to the site. You can do so by visiting <a href="https://sqlite.directory">sqlite.directory</a> and clicking the “Add entry” button. I am looking forward to seeing <em>your</em> project listed on the site!</p> <!--/summary-->Stephen MargheimSelect dropdown for polymorphic associations2024-12-10T00:00:00+00:002024-12-10T00:00:00+00:00repo://posts.collection/_posts/2024-12-11-select-dropdown-for-polymorphic-associations.md<p>When building a CRUD-oriented web application with Ruby on Rails, most things are pretty straightforward. Your tables, models, controllers, and views all naturally align, and you can lean on the Rails scaffolds. One gap, however, is dealing with polymorphic associations in your forms. Let’s explore how <a href="https://github.com/rails/globalid">global IDs</a> can provide us with a simple solution.</p> <!--/summary--> <hr /> <p>For this blog post, let’s consider building an app that has <code>Post</code>s that have polymorphic <code>content</code>, where a post’s content can be either an <code>Article</code> or a <code>Video</code>.</p> <p>We can scaffold such a resource with the Rails CLI:</p> <pre><code class="language-shell">bin/rails generate scaffold Post title:string! content:belongs_to{polymorphic} </code></pre> <p>This command will create a migration file like this:</p> <pre><code class="language-ruby">class CreatePosts &lt; ActiveRecord::Migration[8.0] def change create_table :posts do |t| t.string :title, null: false t.belongs_to :content, polymorphic: true, null: false t.timestamps end end end </code></pre> <p>And a model file like this:</p> <pre><code class="language-ruby">class Post &lt; ApplicationRecord belongs_to :content, polymorphic: true end </code></pre> <p>These both look great, and are how you should build <a href="https://guides.rubyonrails.org/association_basics.html#polymorphic-associations">polymorphic associations</a>. The issue arises when we view the scaffolded <code>_form</code> partial:</p> <pre><code class="language-erb">&lt;%= form_with(model: post) do |form| %&gt; &lt;% if post.errors.any? %&gt; &lt;div style="color: red"&gt; &lt;h2&gt;&lt;%= pluralize(post.errors.count, "error") %&gt; prohibited this post from being saved:&lt;/h2&gt; &lt;ul&gt; &lt;% post.errors.each do |error| %&gt; &lt;li&gt;&lt;%= error.full_message %&gt;&lt;/li&gt; &lt;% end %&gt; &lt;/ul&gt; &lt;/div&gt; &lt;% end %&gt; &lt;div&gt; &lt;%= form.label :title, style: "display: block" %&gt; &lt;%= form.text_field :title %&gt; &lt;/div&gt; &lt;div&gt; &lt;!-- [tl! highlight:3] --&gt; &lt;%= form.label :content_id, style: "display: block" %&gt; &lt;%= form.text_field :content_id %&gt; &lt;/div&gt; &lt;div&gt; &lt;%= form.submit %&gt; &lt;/div&gt; &lt;% end %&gt; </code></pre> <p>This form is <em>not</em> production-ready. We should never ask users to enter table IDs into forms. Were this a simple <code>belongs_to</code> association, I would always reach first for a <a href="https://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#method-i-collection_select"><code>form.collection_select</code></a> and replace this <code>text_field</code> with something like:</p> <pre><code class="language-erb">&lt;div&gt; &lt;%= form.label :content_id, style: "display: block" %&gt; &lt;%= form.collection_select(:content_id, Content.all, :id, :public_name, prompt: true) %&gt; &lt;/div&gt; </code></pre> <p>With a polymorphic association like we have, however, this won’t work because we don’t have a single <code>Content</code> model. Yes, we could potentially reach for <a href="https://api.rubyonrails.org/classes/ActiveRecord/DelegatedType.html">delegated types</a> instead of a polymorphic association, but sometimes a straight polymorphic association is what is best for the database schema. So, how can we build a simple yet elegant form experience for a polymorphic association?</p> <p>I won’t bury the lede; my answer is to reach for <a href="https://github.com/rails/globalid">global IDs</a>. Let me explain why. Simple means no (or very few) moving parts. I don’t want two dependent <code>&lt;select&gt;</code>s, where the user first selects the type and then the second select only shows the subset of options that are of that type; that requires Javascript and that shouldn’t be a requirement for a simple default. Elegant means easy to use and straightforward to build. Having one form field and a diff of no more than 10 lines of code is a good rule of thumb for elegance in this scenario. A solution built on top of global IDs allow me to build a simple and elegant solution.</p> <p>Let’s start with the form field. Instead of two dependent <code>&lt;select&gt;</code>s, let’s build a single <code>&lt;select&gt;</code> with grouped options. This allows the user to clearly see that this is a polymorphic association as well as which type each option is. Here is the example of a grouped select from the <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup">MDN docs on <code>optgroup</code></a>:</p> <div> <label for="dino-select">Choose a dinosaur:</label> <select id="dino-select" style="color: black"> <optgroup label="Theropods"> <option>Tyrannosaurus</option> <option>Velociraptor</option> <option>Deinonychus</option> </optgroup> <optgroup label="Sauropods"> <option>Diplodocus</option> <option>Saltasaurus</option> <option>Apatosaurus</option> </optgroup> </select> </div> <p>Our list of options are grouped with non-selectable headers. Rails has a companion form helper for building such <code>&lt;select&gt;</code>s with the <a href="https://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#method-i-grouped_collection_select"><code>grouped_collection_select</code></a> helper, which requires passing a single collection that can be nested via getter methods. The example in the docs are continents that have many countries each of which has many cities, so you can do <code>form.grouped_collection_select(:country_id, @continents, :countries, :name, :id, :name)</code>. With our polymorphic association, we can’t easy get a single collection of all possible <code>content</code> values, so instead of using <code>grouped_collection_select</code>, we can drop down and use <a href="https://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#method-i-grouped_options_for_select"><code>grouped_options_for_select</code></a> helper instead with our class <code>form.select</code></p> <pre><code class="language-erb">&lt;div&gt; &lt;%= form.label :content_gid, style: "display: block" %&gt; &lt;%= form.select(:content_gid, grouped_options_for_select( [ [ 'Articles', Article .order(:title) .map { |it| [it.title, it.to_gid.to_s] } ], [ 'Videos', Video .order(:title) .map { |it| [it.title, it.to_gid.to_s] } ] ] )) %&gt; &lt;/div&gt; </code></pre> <p>Here we build up our grouped options and pass that as the choices to the <code>form.select</code> helper. Our grouped options is a basic array of arrays, where each top-level array is a group. A group has first a string heading and then an inner array of choice tuples. This is where we turn to global IDs. A choice tuple takes a <em>label</em> and then a <em>value</em>. The label is what is shown to users and the value is what is sent back to the server. Our values need to encode both the <code>id</code> and the <code>type</code> of this particular choice for the post’s <code>content</code>. And this is precisely what global IDs provide us. As the <a href="https://github.com/rails/globalid"><code>globalid</code> docs</a> state:</p> <blockquote> <p>A Global ID is an app wide URI that uniquely identifies a model instance:</p> <pre><code>gid://YourApp/Some::Model/id </code></pre> <p>This is helpful when you need a single identifier to reference different classes of objects.</p> </blockquote> <p>By encoding both the class name and the ID, global IDs provide all of the information we need to set our polymorphic association. All we need is a new accessor on our model to get and set our association via global IDs:</p> <pre><code class="language-ruby">class Post &lt; ApplicationRecord belongs_to :content, polymorphic: true def content_gid content&amp;.to_gid end def content_gid=(gid) self.content = GlobalID::Locator.locate gid end end </code></pre> <p>Easy enough! In addition to our <code>Post#content_id</code> accessor we add a <code>Post#content_gid</code> accessor whose getter returns the associated <code>content</code>’s global ID and whose setter takes a global ID and uses it to set the full <code>content</code> association.</p> <p>So, instead of using a flat collection of choices in a <code>&lt;select&gt;</code> for a standard <code>belongs_to</code> association via the <code>content_id</code> field, when I am working with a <em>polymorphic</em> association I reach for a grouped collection of choices via the <code>content_gid</code> field.</p> <p>If you want to ensure that <em>every</em> ActiveRecord model with a polymorphic <code>belongs_to</code> association has this <code>*_gid</code> accessor, you can add the following initializer to your Rails app:</p> <pre><code class="language-ruby"># config/initializers/polymorphic_belongs_to_gid.rb ActiveSupport.on_load(:active_record) do module_parent.const_get('Associations::Builder::BelongsTo').class_eval do def self.define_accessors(model, reflection) super return unless reflection.polymorphic? mixin = model.generated_association_methods name = reflection.name mixin.class_eval &lt;&lt;-CODE, __FILE__, __LINE__ + 1 def #{name}_gid public_send(:#{name})&amp;.to_gid end def #{name}_gid=(global_id) value = GlobalID::Locator.locate global_id association(:#{name}).writer(value) end CODE end end end </code></pre> <p>This patches Active Record’s association builder to additionally define the <code>*_gid</code> accessors for polymorphic <code>belongs_to</code> associations. This way, you can always reach for <code>*_gid</code> accessors when working with polymorphic associations.</p> <hr /> <p class="notice"><strong>Update</strong> <em>(11-12-2024)</em></p> <p><a href="https://bsky.app/profile/kaspth.bsky.social/post/3lcxng4pxks2h">A</a> <a href="https://x.com/matheusrich/status/1866662370040332415">number</a> <a href="https://x.com/TheDumbTechGuy/status/1866531782214160708">of</a> <a href="https://ruby.social/@henrik/113630983647966057">people</a> chimed in to point out the using <a href="https://github.com/rails/globalid?tab=readme-ov-file#signed-global-ids"><em>signed</em> global IDs</a> would prevent the potential for client-side tampering. This is a great point and a worthy tweak. The code doesn’t get notably more complicated, but the functionality does get notably more secure. So, let’s update our setup to use signed global IDs instead of plaintext global IDs.</p> <p>First, our methods (whether manually added or generated via an initializer) should read and write signed global IDs:</p> <pre><code class="language-ruby">class Post &lt; ApplicationRecord belongs_to :content, polymorphic: true def content_gid content&amp;.to_sgid(for: :polymorphic_association, expires_in: nil) end def content_gid=(gid) self.content = GlobalID::Locator.locate_signed(gid, for: :polymorphic_association) end end </code></pre> <p>Here we change <code>.to_gid</code> to <code>.to_sgid</code> (an alias for <code>.to_signed_global_id</code>) and pass both an expiration and a purpose. We don’t want this signed global ID to expire, so we pass <code>expires_in: nil</code> (default is that they expire in 1 month). The purpose is a string that helps ensure that the signed global ID is only used for this one intended purpose. This is a security feature that helps prevent signed global IDs from being used in unintended ways. Next, we change <code>GlobalID::Locator.locate</code> to <code>GlobalID::Locator.locate_signed</code> and pass the same purpose.</p> <p>Our form now needs to use the signed global ID as the value in the <code>&lt;option&gt;</code>s. And this is a great opportunity to clean up our form code a bit. The current version is a bit verbose:</p> <pre><code class="language-erb">&lt;div&gt; &lt;%= form.label :content_gid, style: "display: block" %&gt; &lt;%= form.select(:content_gid, grouped_options_for_select( [ [ 'Articles', Article .order(:title) .map { |it| [it.title, it.to_gid.to_s] } ], [ 'Videos', Video .order(:title) .map { |it| [it.title, it.to_gid.to_s] } ] ] )) %&gt; &lt;/div&gt; </code></pre> <p>And, it doesn’t enforce that the different possible <code>content</code> models need to adhere to a shared interface. So, let’s create a model concern that we can include into all “contentable” models to define and implement the needed interface:</p> <pre><code class="language-ruby"># app/models/concerns/contentable.rb module Contentable extend ActiveSupport::Concern class_methods do def to_options ordered.map { |it| [it.public_name, it.to_sgid(for: :polymorphic_association, expires_in: nil)] } end end included do scope :ordered, -&gt; { order(**public_order) } end def public_name raise NotImplementedError end def public_order raise NotImplementedError end end </code></pre> <p>Now, we could use this <code>Contentable</code> concern in our <code>Image</code> and <code>Video</code> models. For example, like this:</p> <pre><code class="language-ruby">class Article &lt; ApplicationRecord include Contentable def public_name = title def public_order = { title: :asc } end </code></pre> <p>By defining and implementing the interface that the models that can be used in the polymorphic <code>content</code> association need to adhere to, we can now simplify our form code:</p> <pre><code class="language-erb">&lt;div&gt; &lt;%= form.label :content_gid, style: "display: block" %&gt; &lt;%= form.select(:content_gid, grouped_options_for_select( [ [ 'Articles', Article.to_options ], [ 'Videos', Video.to_options ] ] )) %&gt; &lt;/div&gt; </code></pre> <p>All together, these are solid improvements that make our implementation both more secure and more maintainable.</p> <p>Thanks for the great feedback and for making this code even better!</p>Stephen Margheim