<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://railsdesigner.com/" version="0.18.0">Perron</generator>
  <id>https://railsdesigner.com/feed.xml</id>
  <title>Rails Designer Blog</title>
  <subtitle>Articles from Rails Designer Blog</subtitle>
  <link href="https://railsdesigner.com/feed.xml" rel="self" type="application/atom+xml"/>
  <link href="https://railsdesigner.com/" rel="alternate" type="text/html"/>
  <updated>2026-03-12T07:30:00Z</updated>
  <author>
    <name>Rails Designer</name>
    <email>support@railsdesigner.com</email>
  </author>
  <entry>
    <title>Video Preview on Hover with Stimulus</title>
    <link href="https://railsdesigner.com/video-hover-preview-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-03-12T07:30:00Z</published>
    <updated>2026-03-12T07:30:00Z</updated>
    <id>https://railsdesigner.com/video-hover-preview-stimulus/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/video-hover-preview-stimulus/?ref=rss"><![CDATA[<p>Building on the <a href="https://railsdesigner.com/recording-video-stimulus/">video recording feature from earlier</a>, let’s add a nice touch to the presentation index page: video previews that play on hover. You know the experience—hover over a video thumbnail and get a quick preview of what’s inside. It’s the same interaction you see on YouTube, Netflix  and every modern video platform.</p>
<h2>
<a href="#the-presentations-index" aria-hidden="true" class="anchor" id="the-presentations-index"></a>The presentations index</h2>
<p>After recording presentations, you need a way to browse them. A simple index page lists all presentations with video thumbnails. When you hover over a thumbnail, the video plays a preview. Move your cursor away and it returns to the poster image.</p>
<p>Here’s the basic setup:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;% @</span><span style="color:#1e293b;">presentations</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">each </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">presentation</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> video_tag presentation</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#075985;">width</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">160</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#075985;">poster</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">presentation</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">representable? </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#0284c7;">url_for</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">presentation</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">representation</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">resize</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">160x120</span><span style="color:#475569;">")) </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">),
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#075985;">controller</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">preview</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">mouseenter-&gt;preview#play mouseleave-&gt;preview#pause</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Active Storage’s <code>representable?</code> method checks if a preview can be generated and <code>representation()</code> creates the thumbnail automatically.</p>
<h2>
<a href="#the-preview-controller" aria-hidden="true" class="anchor" id="the-preview-controller"></a>The preview controller</h2>
<p>All the logic happens in one Stimulus controller:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">values </span><span style="color:#0c4a6e;">= {
</span><span style="color:#0c4a6e;">    segments: { type: Number, default: 3 </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">interval</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">{ </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Number</span><span style="color:#475569;">, </span><span style="color:#1e293b;">default</span><span style="color:#0c4a6e;">: </span><span style="font-weight:bold;color:#d97706;">1000 </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">minDuration</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">{ </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Number</span><span style="color:#475569;">, </span><span style="color:#1e293b;">default</span><span style="color:#0c4a6e;">: </span><span style="font-weight:bold;color:#d97706;">5 </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">connect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">originalTime </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">wasPlaying </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">previewTimer </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentIndex </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">isReady </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">loadedmetadata</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">calculateTimestamps</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">isReady </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<h2>
<a href="#understanding-loadedmetadata" aria-hidden="true" class="anchor" id="understanding-loadedmetadata"></a>Understanding loadedmetadata</h2>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadedmetadata_event"><code>loadedmetadata</code></a> event is the key here. It fires when the browser has loaded enough of the video to know its duration, dimensions and other metadata. Without this information, you can’t calculate meaningful preview timestamps.</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">loadedmetadata</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">calculateTimestamps</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">isReady </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#475569;">})
</span></code></pre>
<p>Only after <code>loadedmetadata</code> fires can you access <code>this.element.duration</code> reliably. Try to use it before this event and you’ll get <code>NaN</code> or <code>0</code>.</p>
<h2>
<a href="#smart-timestamping" aria-hidden="true" class="anchor" id="smart-timestamping"></a>Smart timestamping</h2>
<p>Instead of just playing from the beginning, the controller shows different parts of the video:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">calculateTimestamps</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">duration </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">duration
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">duration </span><span style="font-weight:bold;color:#0369a1;">&lt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">minDurationValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">let </span><span style="color:#1e293b;">i </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">; </span><span style="color:#1e293b;">i </span><span style="font-weight:bold;color:#0369a1;">&lt;= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">segmentsValue</span><span style="color:#475569;">; </span><span style="color:#1e293b;">i</span><span style="font-weight:bold;color:#0369a1;">++</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">((</span><span style="color:#1e293b;">duration </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">segmentsValue </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">)) </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#1e293b;">i</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>For short videos (under 5 seconds), it just plays from the start. For longer videos, it divides the duration into segments and cycles through them. A 60-second video with 3 segments shows clips at 15, 30 and 45 seconds.</p>
<h2>
<a href="#the-element-methods" aria-hidden="true" class="anchor" id="the-element-methods"></a>The element methods</h2>
<p>Stimulus gives you direct access to the video element through <code>this.element</code>. Here are the key methods and properties used:</p>
<ul>
<li>
<code>this.element.duration</code> for the total video length in seconds</li>
<li>
<code>this.element.currentTime</code> for current playback position</li>
<li>
<code>this.element.paused</code> is a boolean indicating if video is paused</li>
<li>
<code>this.element.play()</code> to start playback</li>
<li>
<code>this.element.pause()</code> to pause playback</li>
<li>
<code>this.element.muted</code> is a boolean to control audio during preview</li>
</ul>
<p>The preview controller leverages these to create smooth hover interactions:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">play</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">isReady </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">originalTime </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentTime
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">wasPlaying </span><span style="font-weight:bold;color:#0369a1;">= !</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">paused
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentIndex </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">muted </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">showNextTimestamp</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">previewTimer </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">setInterval</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">showNextTimestamp</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">intervalValue</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>When you hover, it saves the current state, mutes the audio and starts cycling through preview segments. When you leave, it restores everything exactly as it was.</p>
<h2>
<a href="#auto-looping-previews" aria-hidden="true" class="anchor" id="auto-looping-previews"></a>Auto-looping previews</h2>
<p>The preview automatically loops through different parts of the video:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">showNextTimestamp</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentTime </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps</span><span style="color:#475569;">[</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentIndex</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">play</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentIndex </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">currentIndex </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">% </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length
</span><span style="color:#475569;">}
</span></code></pre>
<p>The modulo operator (<code>%</code>) creates the loop: when it reaches the last segment, it goes back to the first. Combined with <code>setInterval</code>, this creates a cycling preview that gives your users a real sense of the video content.</p>
<hr>
<p>The key is waiting for the right moment (when metadata loads), calculating smart preview points and cleaning up properly when the interaction ends. This preview controller shows how a small Stimulus controller can create a nice UX. By understanding browser events like <code>loadedmetadata</code> and leveraging the video element’s built-in methods, you can get a lot done without writing a lot of JavaScript. 🥳</p>
]]></content>
  </entry>
  <entry>
    <title>Understanding importmap-rails</title>
    <link href="https://railsdesigner.com/importmap-rails/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-03-05T07:30:00Z</published>
    <updated>2026-03-05T07:30:00Z</updated>
    <id>https://railsdesigner.com/importmap-rails/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/importmap-rails/?ref=rss"><![CDATA[<p>If you’ve worked with modern JavaScript, you’re familiar with ES modules and <code>import</code> statements. Rails apps can use esbuild (or vite or bun) for this, but the default option (Rails way) is <a href="https://github.com/rails/importmap-rails">importmap-rails</a>. It lets you write <code>import { Controller } from "@hotwired/stimulus"</code> without any build step at all.</p>
<p>Ever thought about how this works?</p>
<h2>
<a href="#import-maps-just-a-web-standard" aria-hidden="true" class="anchor" id="import-maps-just-a-web-standard"></a>Import maps, just a web standard</h2>
<p>Import maps are a <a href="https://html.spec.whatwg.org/multipage/webappapis.html#import-maps">web standard</a> that tells browsers how to resolve bare module specifiers. A bare module specifier looks like <code>import React from "react"</code>, which isn’t valid ESM on its own. The browser needs an absolute path (<code>/assets/react.js</code>), relative path (<code>./react.js</code>), or HTTP URL (<code>https://cdn.example.com/react.js</code>).</p>
<p>Import maps provide the translation:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">script </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">importmap</span><span style="color:#475569;">"&gt;
</span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">"</span><span style="color:#0369a1;">imports</span><span style="color:#475569;">"</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">application</span><span style="color:#475569;">"</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">/assets/application-abc123.js</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">/assets/stimulus.min-def456.js</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers/application</span><span style="color:#475569;">"</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">/assets/controllers/application-ghi789.js</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">script</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>When your JavaScript says <code>import { Controller } from "@hotwired/stimulus"</code>, the browser looks up <code>"@hotwired/stimulus"</code> in this map and loads <code>/assets/stimulus.min-def456.js</code>.</p>
<p>The <code>importmap-rails</code> gem generates this script tag for you. It appears in your layout via <code>&lt;%= javascript_importmap_tags %&gt;</code>, which reads your <code>config/importmap.rb</code> configuration and outputs the importmap along with modulepreload links (which tell the browser to start downloading your JavaScript files immediately, rather than waiting to discover each import one at a time) and your application entry point.</p>
<h2>
<a href="#configuring-with-pin" aria-hidden="true" class="anchor" id="configuring-with-pin"></a>Configuring with pin</h2>
<p>In <code>config/importmap.rb</code>, you define what goes in that map:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">pin </span><span style="color:#475569;">"</span><span style="color:#0369a1;">application</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">pin </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/turbo-rails</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo.min.js</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">pin </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">stimulus.min.js</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">pin </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@rails/request.js</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@rails--request.js.js</span><span style="color:#475569;">"
</span></code></pre>
<p>Each <code>pin</code> creates a mapping (entry) in the importmap. The first argument is the bare module specifier you’ll write in your <code>import</code> statement. The <code>to:</code> attribute specifies which file should be loaded from your asset pipeline (typically from <code>app/javascript</code> or <code>vendor/javascript</code>).</p>
<p>To add a package from npm, run:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">./bin/importmap</span><span style="color:#0c4a6e;"> pin package-name
</span></code></pre>
<p>This downloads the package file into <code>vendor/javascript</code> and adds the pin to your <code>config/importmap.rb</code>. The default is to use <a href="https://jspm.org">JSPM.org</a> as the CDN, but you can specify others:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">./bin/importmap</span><span style="color:#0c4a6e;"> pin react</span><span style="color:#475569;"> --</span><span style="color:#1e293b;">from</span><span style="color:#0c4a6e;"> unpkg
</span><span style="color:#1e293b;">./bin/importmap</span><span style="color:#0c4a6e;"> pin react</span><span style="color:#475569;"> --</span><span style="color:#1e293b;">from</span><span style="color:#0c4a6e;"> jsdelivr
</span></code></pre>
<p>The downloaded files are checked into your source control and served through your application’s asset pipeline.</p>
<h2>
<a href="#mapping-directories-with-pin_all_from" aria-hidden="true" class="anchor" id="mapping-directories-with-pin_all_from"></a>Mapping directories with pin_all_from</h2>
<p>Instead of pinning files individually, you can map entire directories:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/controllers</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/turbo_stream_actions</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions</span><span style="color:#475569;">"
</span></code></pre>
<p>The <code>under:</code> attribute creates a namespace prefix. Every file in the directory becomes “importable” with that prefix.</p>
<p>So <code>app/javascript/controllers/reposition_controller.js</code> becomes:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#1e293b;">RepositionController </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers/reposition_controller</span><span style="color:#475569;">"
</span></code></pre>
<p>And <code>app/javascript/turbo_stream_actions/set_data_attribute.js</code> becomes:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#1e293b;">set_data_attribute </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions/set_data_attribute</span><span style="color:#475569;">"
</span></code></pre>
<h2>
<a href="#example-custom-turbo-stream-actions" aria-hidden="true" class="anchor" id="example-custom-turbo-stream-actions"></a>Example: custom Turbo Stream actions</h2>
<p>Let’s now look at how all these pieces connect. Say you want to register a custom Turbo Stream action (as I wrote about <a href="/update-page-title-turbo/">here</a> and <a href="/update-favicon-badge-turbo-stream/">here</a>).</p>
<p>First, in <code>config/importmap.rb</code>, you map the directory:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/turbo_stream_actions</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions</span><span style="color:#475569;">"
</span></code></pre>
<p>When Rails generates the importmap (via <code>&lt;%= javascript_importmap_tags %&gt;</code>), it scans that directory and creates entries for each file:</p>
<pre lang="json" style="background-color:#f8fafc;"><code><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">"</span><span style="color:#0369a1;">imports</span><span style="color:#475569;">": {
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions</span><span style="color:#475569;">": "</span><span style="color:#0369a1;">/assets/turbo_stream_actions/index-abc.js</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions/set_data_attribute</span><span style="color:#475569;">": "</span><span style="color:#0369a1;">/assets/turbo_stream_actions/set_data_attribute-xyz.js</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>Now you can create your custom action at <code>app/javascript/turbo_stream_actions/set_data_attribute.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">export default function</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> your custom logic
</span><span style="color:#475569;">}
</span></code></pre>
<p>And register it in <code>app/javascript/turbo_stream_actions/index.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#1e293b;">set_data_attribute </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions/set_data_attribute</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">Turbo</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">StreamActions</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">set_data_attribute </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">set_data_attribute
</span></code></pre>
<p>The browser sees <code>"turbo_stream_actions/set_data_attribute"</code>, looks it up in the importmap, finds <code>/assets/turbo_stream_actions/set_data_attribute-xyz.js</code> and loads it.</p>
<p>Finally, in your main <code>app/javascript/application.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions</span><span style="color:#475569;">"
</span></code></pre>
<p>Your custom action is now registered. The import path (<code>"turbo_stream_actions/set_data_attribute"</code>) matches the <code>under:</code> namespace from your <code>pin_all_from</code> configuration.</p>
<h2>
<a href="#how-stimulus-controllers-use-this" aria-hidden="true" class="anchor" id="how-stimulus-controllers-use-this"></a>How Stimulus controllers use this</h2>
<p>The same pattern is used for Stimulus. In <code>config/importmap.rb</code>:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/controllers</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers</span><span style="color:#475569;">"
</span></code></pre>
<p>The <code>under:</code> value here could be anything: <code>"my_controllers"</code> or <code>"stimulus_controllers"</code>. But <code>"controllers"</code> is the convention. It becomes the import prefix for everything in that directory.</p>
<p>In <code>app/javascript/controllers/index.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">application </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers/application</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">eagerLoadControllersFrom </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus-loading</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">eagerLoadControllersFrom</span><span style="color:#475569;">("</span><span style="color:#0369a1;">controllers</span><span style="color:#475569;">", </span><span style="color:#1e293b;">application</span><span style="color:#475569;">)
</span></code></pre>
<p>The <code>application</code> import comes from <code>app/javascript/controllers/application.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Application </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">application </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">Application</span><span style="color:#475569;">.</span><span style="color:#1e293b;">start</span><span style="color:#475569;">()
</span><span style="color:#1e293b;">application</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">debug </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">Stimulus </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">application
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">application </span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>eagerLoadControllersFrom</code> function scans the importmap for entries starting with <code>"controllers/"</code> and automatically imports and registers them. Add a new controller file and it’s instantly available.</p>
<p>Because of <code>pin_all_from</code> with <code>under: "controllers"</code>, the file <code>app/javascript/controllers/application.js</code> is “importable” as <code>"controllers/application"</code>. The pattern is the same: directory structure maps to import paths through the <code>under:</code> namespace.</p>
]]></content>
  </entry>
  <entry>
    <title>Welcome to a new Rails Designer</title>
    <link href="https://railsdesigner.com/new-rails-designer/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-26T07:30:00Z</published>
    <updated>2026-02-26T07:30:00Z</updated>
    <id>https://railsdesigner.com/new-rails-designer/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/new-rails-designer/?ref=rss"><![CDATA[<p>In January 2024 I published the <a href="/viewcomponents-and-tailwindcss/">first article on Rails Designer’s blog</a>. It listed some of my best practices; most of which I still use today.</p>
<p>Over those two years there were almost 200 articles published on topics ranging from Hotwire, to Rails and Tailwind CSS. Next to that I’ve launched various tools <a href="/open-source/">and dozen open source projects</a>. With all those articles, tools and other pages, the site slowly started to feel incoherent. Less consistent in style, feel and branding. So it was time to lose some weight and put on some fresh clothes! 🎩💅</p>
<p>I spent some time recently to build the site from scratch. Not coincidentally this was a good time to build it using <a href="https://github.com/rails-designer/perron">Perron, the Rails-based static site generator</a> I launched about seven months ago. If you haven’t checked it out, please do! I think you will like it for your next marketing site, docs or landing page.</p>
<p><a href="/">Have a look around</a>.</p>
<p>Over time I will tighten things here and there, but if you find glaring issues or certain missing, please <a href="#comments">comment below</a>. 😊</p>
<p>If you want to read about the tech details, continue reading! 👇</p>
<hr>
<h2>
<a href="#building-a-medium-sized-site-with-perron" aria-hidden="true" class="anchor" id="building-a-medium-sized-site-with-perron"></a>Building a medium-sized site with Perron</h2>
<p>Rails Designer’s site is built with <a href="https://perron.railsdesigner.com/">Perron</a>. It is the Rails-based SSG. While that certainly might sound weird (isn’t Rails way too big for just static sites?!), it is surprising lean and fast to work with.</p>
<p>I develop the site, rendering the site running<code>rails server</code> (<code>bin/dev</code>). Perron, via the new <a href="https://github.com/rails-designer/mata">Mata gem</a> auto-refreshes the page, making it easy to see what I fixed (or messed up). Once I am ready to publish, I run <code>bin/rails perron:build</code> and, presto: you are looking at the static-build site.</p>
<p>Most of the standard Rails libraries are disabled, the only ones added are:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> config/application.rb
</span><span style="font-weight:bold;color:#dc2626;">require_relative </span><span style="color:#475569;">"</span><span style="color:#0369a1;">boot</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">require </span><span style="color:#475569;">"</span><span style="color:#0369a1;">active_model/railtie</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">require </span><span style="color:#475569;">"</span><span style="color:#0369a1;">action_controller/railtie</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">require </span><span style="color:#475569;">"</span><span style="color:#0369a1;">action_view/railtie</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">Bundler</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">require</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">*</span><span style="color:#0c4a6e;">Rails</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">groups</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">RailsDesigner
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Application </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Rails</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Application
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span></code></pre>
<p>Perron works with collections, like posts, articles or features. Those you are likely familiar with you already. Let’s look at the posts collection, for example.</p>
<p>First the routes:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">Rails</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">application</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">routes</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">draw </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">  resources </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">articles</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#dc2626;">module</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">path</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components/docs</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">only</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">index show</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  resources </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">changelogs</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#dc2626;">module</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">path</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components/changelog</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">only</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">index</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  resources </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">components</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#dc2626;">module</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">only</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">index show</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  get </span><span style="color:#475569;">"</span><span style="color:#0369a1;">version.json</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/versions#show</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">as</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">version
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> ✂️
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  resources </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">posts</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#dc2626;">module</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">path</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">articles</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">only</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">index show</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> ✂️
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  root </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/pages#root</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>These are just Rails’ routes you are already familiar with, right?! Let’s see the <code>Content::PostsController</code>:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Content</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">PostsController </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationController
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">index
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">metadata </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resources </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Content</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Post</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">all
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">show
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Content</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Post</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">find!</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">id</span><span style="color:#475569;">])
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Also just vanilla Rails here! Then one more step: the views. First the index view:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component </span><span style="color:#475569;">"</span><span style="color:#0369a1;">hero</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">heading</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Rails UI Engineering Articles</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">description</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Practical guides to build better Rails UI. I share the patterns and approaches I use in my daily work with Hotwire, Rails and modern front-end practices.</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component </span><span style="color:#475569;">"</span><span style="color:#0369a1;">container</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">section </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid gap-y-2</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component</span><span style="color:#475569;">("</span><span style="color:#0369a1;">heading</span><span style="color:#475569;">") { "</span><span style="color:#0369a1;">Latest articles</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">ul </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid grid-cols-1 gap-4 md:grid-cols-2 lg:gap-6</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="font-weight:bold;color:#075985;">partial</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">post_card</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">collection</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resources</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recently_published</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">limit</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">4</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">as</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">post </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">ul</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">section</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">section </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">mt-8 md:mt-10</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component</span><span style="color:#475569;">("</span><span style="color:#0369a1;">heading</span><span style="color:#475569;">") { "</span><span style="color:#0369a1;">Popular articles</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">mt-0.5 text-sm text-gray-600 lg:text-base</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      Based on views using the Wilson Score with time decay.
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">ol </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid grid-cols-1 gap-4 mt-4 md:grid-cols-2 lg:gap-6</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="font-weight:bold;color:#075985;">partial</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">post_card</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">collection</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resources</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">featured</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">limit</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">4</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">as</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">post </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">ol</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">section</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">&lt;!--</span><span style="font-style:italic;color:#64748b;"> ✂️ </span><span style="font-style:italic;color:#475569;">--&gt;
</span></code></pre>
<p>Pretty clean and still looks like your average Rails view. How about the show template?</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">article</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component </span><span style="color:#475569;">"</span><span style="color:#0369a1;">hero</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">heading</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">title </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component</span><span style="color:#475569;">("</span><span style="color:#0369a1;">container</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">additional_css</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">grid grid-cols-12 lg:gap-20</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">col-span-12 content md:col-span-9</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> markdownify </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">process</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">["</span><span style="color:#0369a1;">lazy_load_images</span><span style="color:#475569;">", </span><span style="color:#1e293b;">CopyableCodeProcessor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">StyledBlockquoteProcessor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">NofollowProcessor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">AbsoluteImagesProcessor</span><span style="color:#475569;">] %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/posts/aside</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">resource</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/posts/dialog</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">resource</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/posts/comments</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">section </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">col-span-12 px-2 py-3 bg-gray-50 border border-gray-100 rounded-lg md:col-span-9 md:px-4 md:py-6 max-md:mt-8</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> component</span><span style="color:#475569;">("</span><span style="color:#0369a1;">heading</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">level</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">h2</span><span style="color:#475569;">) { "</span><span style="color:#0369a1;">Want to read me more?</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">ul </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid gap-y-4 mt-4</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">        </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="font-weight:bold;color:#075985;">partial</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">content/posts/post</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">collection</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">related_resources</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">limit</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">3</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">as</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">post</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">locals</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">description</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">} %&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">ul</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">section</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">article</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>See how resources, which are erb or markdown files stored in <code>app/content/posts</code>, use <code>recently_published</code>, <code>featured</code> and <code>limit</code>? Those are, indeed, scopes defined on the content/post model:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/models/content/post.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Content</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Post </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Perron</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Resource
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#1e293b;">Categories
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  configure </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">config</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">    config</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">feeds</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">atom</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">enabled </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    config</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">sitemap</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">priority </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0.4
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  delegate </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">title</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">description</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">category</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">section</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">featured</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">updated_at</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">to</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">metadata
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  scope </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">featured</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#0369a1;">-&gt; </span><span style="color:#475569;">{</span><span style="color:#0c4a6e;"> where</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">featured</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">) }
</span><span style="color:#0c4a6e;">  scope </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">recently_published</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#0369a1;">-&gt; </span><span style="color:#475569;">{</span><span style="color:#0c4a6e;"> order</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">published_at</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">desc</span><span style="color:#475569;">) }
</span><span style="color:#0c4a6e;">  scope </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">earliest_published</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#0369a1;">-&gt; </span><span style="color:#475569;">{</span><span style="color:#0c4a6e;"> order</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">published_at</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">asc</span><span style="color:#475569;">) }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  validates </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">category</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">inclusion</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">in</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">Content</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Post</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">CATEGORIES</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">keys</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">map</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">&amp;</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">to_s</span><span style="color:#475569;">)}, </span><span style="font-weight:bold;color:#075985;">if</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#0369a1;">-&gt; </span><span style="color:#475569;">{</span><span style="color:#0c4a6e;"> section </span><span style="font-weight:bold;color:#0369a1;">== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">general</span><span style="color:#475569;">" }
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>The only thing you notice that is different from a regular Active Model resource is the parent class: <code>Perron::Resource</code>, but otherwise it “quacks” much the same.</p>
<h2>
<a href="#auto-pull-data-to-create-resources" aria-hidden="true" class="anchor" id="auto-pull-data-to-create-resources"></a>Auto pull data to create resources</h2>
<p>Perron offers many features on top it all, like feeds, sitemap and data resource handling. The latter is one I like to highlight now.</p>
<p>I like to <a href="/open-source/">list all the open source projects</a> I built and maintain. I am ashamed to admit that previously I built all these pages manually. I just never came around automating it. But also: my previous SSG didn’t have a clear way of doing. Perron does, via the <a href="https://perron.railsdesigner.com/docs/programmatic-content-creation/">programmatic content feature</a>.</p>
<p>This is how it works. I list all projects I want to include in <code>app/content/data/tools.yml</code>:</p>
<pre lang="yaml" style="background-color:#f8fafc;"><code><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">rails-icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Rails Icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/rails_icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">position</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://rubygems.org/gems/rails_icons
</span><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">courrier
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Courrier
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/courrier
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://rubygems.org/gems/courrier
</span><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/icons
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://rubygems.org/gems/icons
</span><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">mata
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Mata
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/mata
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://rubygems.org/gems/mata
</span><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">requestkit
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Requestkit
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/requestkit
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://rubygems.org/gems/requestkit
</span><span style="color:#475569;">- </span><span style="font-weight:bold;color:#dc2626;">id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">turbo-transition
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">Turbo Transition
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">category</span><span style="color:#475569;">: </span><span style="color:#0369a1;">oss
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">github_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://github.com/rails-designer/turbo-transition
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">package_registry_url</span><span style="color:#475569;">: </span><span style="color:#0369a1;">https://www.npmjs.com/package/turbo-transition
</span><span style="color:#0369a1;">// …
</span></code></pre>
<p>I then define the template:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Content</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Tool </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Perron</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Resource
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#1e293b;">GithubFetch</span><span style="color:#475569;">, </span><span style="color:#1e293b;">RubygemsFetch
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  source </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">tools
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#1e293b;">self</span><span style="color:#475569;">.</span><span style="color:#0284c7;">source_template</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sources</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    tool </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> sources</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">tools
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return if</span><span style="color:#0c4a6e;"> tool</span><span style="color:#475569;">.</span><span style="color:#0284c7;">respond_to?</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">url</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;</span><span style="color:#0c4a6e;"> tool</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">url</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">present?
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;&lt;~TEMPLATE
</span><span style="color:#0369a1;">      ---
</span><span style="color:#0369a1;">      category: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">.</span><span style="color:#0369a1;">category</span><span style="color:#475569;">}
</span><span style="color:#0369a1;">      title: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">.</span><span style="color:#0284c7;">name</span><span style="color:#475569;">}
</span><span style="color:#0369a1;">      description: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">description_for</span><span style="color:#475569;">(</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">)}
</span><span style="color:#0369a1;">      github_url: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">.</span><span style="color:#0369a1;">github_url</span><span style="color:#475569;">}
</span><span style="color:#0369a1;">      github_stars: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">stars_for</span><span style="color:#475569;">(</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">)}
</span><span style="color:#0369a1;">      package_registry_url: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">.</span><span style="color:#0369a1;">package_registry_url</span><span style="color:#475569;">}
</span><span style="color:#0369a1;">      package_downloads: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">downloads_for</span><span style="color:#475569;">(</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">)}
</span><span style="color:#0369a1;">      ---
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">      </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">content_for</span><span style="color:#475569;">(</span><span style="color:#0369a1;">tool</span><span style="color:#475569;">)}
</span><span style="color:#475569;">    TEMPLATE
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p><code>GithubFetch</code> and <code>RubygemsFetch</code> have some custom logic to pull the required data, looking something like this:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">Content</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Tool</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">GithubFetch
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">extend </span><span style="color:#0c4a6e;">ActiveSupport</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Concern
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  class_methods </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">description_for</span><span style="color:#475569;">(</span><span style="color:#1e293b;">tool</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return</span><span style="color:#0c4a6e;"> tool</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">description </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> tool</span><span style="color:#475569;">.</span><span style="color:#0284c7;">respond_to?</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">description</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;</span><span style="color:#0c4a6e;"> tool</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">description</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">present?
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      github_data</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">tool</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">github_url</span><span style="color:#475569;">)[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">description</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">stars_for</span><span style="color:#475569;">(</span><span style="color:#1e293b;">tool</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      github_data</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">tool</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">github_url</span><span style="color:#475569;">)[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">stars</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Then all that is need is run <code>bin/rails perron:sync_sources[tools]</code> to update all content and stars + downloads:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">bin/rails</span><span style="color:#0c4a6e;"> perron:sync_sources
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> RubyGems downloads for rails_icons…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/rails_icons…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> RubyGems downloads for courrier…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/courrier…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> RubyGems downloads for icons…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/icons…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> RubyGems downloads for mata…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/mata…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> RubyGems downloads for requestkit…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/requestkit…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> NPM downloads for turbo-transition…
</span><span style="color:#1e293b;">Fetching</span><span style="color:#0c4a6e;"> README for rails-designer/turbo-transition…
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span></code></pre>
<p>Pretty cool, right? 😊  I am pretty pleased with it at all.</p>
<p>Let me know if you want me to highlight any other thing <a href="#comments">below in the comments</a>. And if you want big holes or odd rendering issues, do let me know as well. ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Simplifying timestamp toggles in Rails</title>
    <link href="https://railsdesigner.com/timestamp-toggles/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-24T07:30:00Z</published>
    <updated>2026-02-24T07:30:00Z</updated>
    <id>https://railsdesigner.com/timestamp-toggles/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/timestamp-toggles/?ref=rss"><![CDATA[<p>I often use timestamps, like <code>completed_at</code> as a boolean flag. It offers just a bit more (meta) data than a real boolean.</p>
<p>But of course on the UI you want to show a checkbox that a user can toggle instead of a datetime field.</p>
<p>I have done this often enough, that I created a simple concern that I use throughout my apps. Given above <code>completed_at</code> example, it gives you an API like:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">completed?
</span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">complete!
</span><span style="color:#475569;">@</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">complete=
</span></code></pre>
<p>So in your form, you can simply use <code>form.check_box :completed</code> and you’re off.</p>
<p>The concern is simple enough:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> lib/boolean_attributes.rb
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">BooleanAttribute
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">extend </span><span style="color:#0c4a6e;">ActiveSupport</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Concern
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  class_methods </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">boolean_attribute</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">*</span><span style="color:#1e293b;">fields</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      fields</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">each </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">field</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">        column </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#475569;">:"#{</span><span style="font-weight:bold;color:#075985;">field</span><span style="font-weight:bold;color:#475569;">}</span><span style="font-weight:bold;color:#075985;">_at</span><span style="font-weight:bold;color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        </span><span style="color:#0284c7;">define_method</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">field</span><span style="color:#475569;">) { </span><span style="color:#0284c7;">public_send</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">column</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">present? </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        </span><span style="color:#0284c7;">define_method</span><span style="color:#475569;">("#{</span><span style="color:#0369a1;">field</span><span style="color:#475569;">}</span><span style="color:#0369a1;">?</span><span style="color:#475569;">") { </span><span style="color:#0284c7;">public_send</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">column</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">present? </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        </span><span style="color:#0284c7;">define_method</span><span style="color:#475569;">("#{</span><span style="color:#0369a1;">field</span><span style="color:#475569;">}</span><span style="color:#0369a1;">=</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">value</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">          </span><span style="color:#0284c7;">public_send</span><span style="color:#475569;">("#{</span><span style="color:#0369a1;">column</span><span style="color:#475569;">}</span><span style="color:#0369a1;">=</span><span style="color:#475569;">", </span><span style="color:#0c4a6e;">ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Type</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Boolean</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">cast</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">value</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">        </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        </span><span style="color:#0284c7;">define_method</span><span style="color:#475569;">("#{</span><span style="color:#0369a1;">field</span><span style="color:#475569;">}</span><span style="color:#0369a1;">!</span><span style="color:#475569;">") {</span><span style="color:#0c4a6e;"> update!</span><span style="color:#475569;">("#{</span><span style="color:#0369a1;">column</span><span style="color:#475569;">}"</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current</span><span style="color:#475569;">) }
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>In your model use it like this:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Task </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationRecord
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#1e293b;">BooleanAttributes
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  boolean_attribute </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">completed
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>It is just that little concerns that make your life a bit easier.</p>
]]></content>
  </entry>
  <entry>
    <title>Record video in Rails with Stimulus</title>
    <link href="https://railsdesigner.com/recording-video-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-19T07:30:00Z</published>
    <updated>2026-02-19T07:30:00Z</updated>
    <id>https://railsdesigner.com/recording-video-stimulus/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/recording-video-stimulus/?ref=rss"><![CDATA[<p>Early last year <a href="https://railsdesigner.com/rails-ui-consultancy/">I helped a team move from jQuery to Hotwire</a> (you will be surprised how many teams still use jQuery! ❤️ jQuery 4 was released recently; did you know?). It was a fun time (no, really!). One of the more interesting parts was moving from a jQuery plugin for video recording to Stimulus. Today I want to show the outline of how I did that.</p>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder">MediaRecorder API</a> captures video directly in the browser. Webcam, screen sharing or both at once (picture-in-picture style). No external services, no complicated setup. Just modern browser APIs and a well-organized Stimulus controller. Exactly what I like.</p>
<p>Here is what you get:</p>
<ul>
<li>record from your webcam;</li>
<li>record your screen;</li>
<li>record both in picture-in-picture mode (webcam overlay on screen recording);</li>
<li>preview the recording before saving;</li>
<li>save it as an Active Storage attachment.</li>
</ul>
<p>The foundation is simple: a Rails app with a <code>Presentation</code> model that has an attached video. Create a presentation, record it, save it. Done. The interesting part?</p>
<p>This article goes over the interesting parts, for <a href="https://github.com/rails-designer-repos/record-video/">the complete code, see this GitHub repo</a>.</p>
<h2>
<a href="#the-view-structure" aria-hidden="true" class="anchor" id="the-view-structure"></a>The view structure</h2>
<p>Here is what the recording interface looks like:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">recorder</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h2</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">New Recording</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h2</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">click-&gt;recorder#selectMode</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-recorder-mode-param</span><span style="color:#475569;">="</span><span style="color:#0369a1;">webcam</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Webcam</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">click-&gt;recorder#selectMode</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-recorder-mode-param</span><span style="color:#475569;">="</span><span style="color:#0369a1;">screen</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Screen</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">click-&gt;recorder#selectMode</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-recorder-mode-param</span><span style="color:#475569;">="</span><span style="color:#0369a1;">pip</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Picture-in-picture</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">video </span><span style="color:#0369a1;">data-recorder-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">preview</span><span style="color:#475569;">" </span><span style="color:#0369a1;">width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">640</span><span style="color:#475569;">" </span><span style="color:#0369a1;">height</span><span style="color:#475569;">="</span><span style="color:#0369a1;">480</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">video</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-recorder-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">startButton</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">recorder#start</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Start recording</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-recorder-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">stopButton</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">recorder#stop</span><span style="color:#475569;">" </span><span style="color:#0369a1;">disabled</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Stop recording</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h2</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Preview</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h2</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">video </span><span style="color:#0369a1;">data-recorder-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">video</span><span style="color:#475569;">" </span><span style="color:#0369a1;">width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">640</span><span style="color:#475569;">" </span><span style="color:#0369a1;">height</span><span style="color:#475569;">="</span><span style="color:#0369a1;">480</span><span style="color:#475569;">" </span><span style="color:#0369a1;">controls</span><span style="color:#475569;">&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">video</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">presentation</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">recorder_target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" } </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">file_field </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">video</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">recorder_target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">videoInput</span><span style="color:#475569;">" }, </span><span style="font-weight:bold;color:#075985;">hidden</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">click-&gt;recorder#save</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">      Save recording
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Three mode buttons, a preview video (what you see while recording), a recorded video (playback after stopping) and a form to save it. Yes, not looking pretty, but it works!</p>
<h2>
<a href="#one-controller-to-record-them-all" aria-hidden="true" class="anchor" id="one-controller-to-record-them-all"></a>One controller to record them all</h2>
<p>The recorder controller starts with the setup:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/controllers/recorder_controller.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">targets </span><span style="color:#0c4a6e;">= ["preview", "startButton", "stopButton", "video", "form", "videoInput"]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">values </span><span style="color:#0c4a6e;">= { mode: { type: String, default: "webcam" </span><span style="color:#475569;">}</span><span style="color:#0c4a6e;"> }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">connect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedData </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedBlob </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">disconnect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream</span><span style="color:#475569;">) </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getTracks</span><span style="color:#475569;">().</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">track </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">track</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stop</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream</span><span style="color:#475569;">) </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getTracks</span><span style="color:#475569;">().</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">track </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">track</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stop</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>The targets point to the video elements, buttons and form. The mode value tracks which recording type is active (webcam, screen or pip). In <code>connect()</code> the state gets initialized. In <code>disconnect()</code> any active media streams get cleaned up. <a href="https://railsdesigner.com/disconnect-stimulus-controllers/">Always clean up your streams</a>! 🧹</p>
<h3>
<a href="#modes" aria-hidden="true" class="anchor" id="modes"></a>Modes</h3>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">selectMode</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">params</span><span style="color:#475569;">: { </span><span style="color:#1e293b;">mode </span><span style="color:#475569;">} }) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">modeValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">mode
</span><span style="color:#475569;">}
</span></code></pre>
<p>Click a mode button and it updates the <code>modeValue</code>. Simple! <a href="https://railsdesigner.com/smarter-action-parameters/">Stimulus params make this elegant</a>.</p>
<h3>
<a href="#is-this-thing-on" aria-hidden="true" class="anchor" id="is-this-thing-on"></a>Is this thing on?</h3>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">async start</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedData </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#0c4a6e;">stream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">await this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">mediaStream</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">toggleButtons</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">startPreview</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">stream</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">setupRecorder</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">stream</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder</span><span style="color:#475569;">.</span><span style="color:#1e293b;">start</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>Clear any previous recording data, get the media stream for the selected mode, toggle the buttons (disable start, enable stop), show the preview, set up the recorder and start recording. Each step is a small, focused method.</p>
<h3>
<a href="#getusermedia-magic" aria-hidden="true" class="anchor" id="getusermedia-magic"></a>getUserMedia magic</h3>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">async</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">mediaStream</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">streamMethods </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#0284c7;">webcam</span><span style="color:#475569;">: () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#0c4a6e;">navigator</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">mediaDevices</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getUserMedia</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">audio</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">}),
</span><span style="color:#0c4a6e;">    </span><span style="color:#0284c7;">screen</span><span style="color:#475569;">: () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#0c4a6e;">navigator</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">mediaDevices</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getDisplayMedia</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">audio</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">}),
</span><span style="color:#0c4a6e;">    </span><span style="color:#0284c7;">pip</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#dc2626;">async </span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">await </span><span style="color:#0c4a6e;">navigator</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">mediaDevices</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getDisplayMedia</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">audio</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">await </span><span style="color:#0c4a6e;">navigator</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">mediaDevices</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getUserMedia</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">video</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">audio</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">combinedStream</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">await streamMethods</span><span style="color:#475569;">[</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">modeValue</span><span style="color:#475569;">]()
</span><span style="color:#475569;">}
</span></code></pre>
<p>This is where the magic happens, as they say. <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia"><code>getUserMedia</code></a> handles webcam access and <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia"><code>getDisplayMedia</code></a> handles screen sharing. Both are part of the Media Capture and Streams API.</p>
<p>For picture-in-picture mode, both streams get grabbed and combined. More on that in a moment.</p>
<h3>
<a href="#setting-up-the-mediarecorder" aria-hidden="true" class="anchor" id="setting-up-the-mediarecorder"></a>Setting up the MediaRecorder</h3>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">setupRecorder</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">stream</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">MediaRecorder</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">stream</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder</span><span style="color:#475569;">.</span><span style="color:#0284c7;">ondataavailable </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">dataAvailable</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder</span><span style="color:#475569;">.</span><span style="color:#0284c7;">onstop </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">recordingStopped</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">dataAvailable</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">data</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">size </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedData</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">data</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder"><code>MediaRecorder</code></a> API takes a media stream and records it. As data becomes available (in chunks), it gets pushed into the <code>recordedData</code> array. When recording stops, the final output gets handled.</p>
<h3>
<a href="#stop-saving-time" aria-hidden="true" class="anchor" id="stop-saving-time"></a>Stop… saving time</h3>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">stop</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recorder</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stop</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">toggleButtons</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">cleanupStreams</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">recordingStopped</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedBlob </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">Blob</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedData</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">video/webm</span><span style="color:#475569;">" })
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">videoTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">src </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">URL</span><span style="color:#475569;">.</span><span style="color:#1e293b;">createObjectURL</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedBlob</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">clearPreview</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">cleanupStreams</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>When you stop recording, a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob"><code>Blob</code></a> gets created from the recorded chunks and displayed in the preview video using <a href="https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static"><code>URL.createObjectURL</code></a>. This gives you a playable URL for the blob.</p>
<p>Then save it:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">save</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedBlob</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">file </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">File</span><span style="color:#475569;">([</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">recordedBlob</span><span style="color:#475569;">], "</span><span style="color:#0369a1;">recording.webm</span><span style="color:#475569;">", { </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">video/webm</span><span style="color:#475569;">" })
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">dataTransfer </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">DataTransfer</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">dataTransfer</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">items</span><span style="color:#475569;">.</span><span style="color:#0284c7;">add</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">videoInputTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">files </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">dataTransfer</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">files
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">formTarget</span><span style="color:#475569;">.</span><span style="color:#1e293b;">requestSubmit</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>The blob gets converted into a <a href="https://developer.mozilla.org/en-US/docs/Web/API/File"><code>File</code></a> object, added to a <a href="https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer"><code>DataTransfer</code></a> object (this is how you programmatically set file input values) and the form submits. Rails handles the rest with Active Storage (in a real app you likely want to use Active Storage’s Direct Upload feature).</p>
<h3>
<a href="#combine-webcam--screen" aria-hidden="true" class="anchor" id="combine-webcam--screen"></a>Combine webcam + screen</h3>
<p>This is the coolest part, I think. To combine screen and webcam streams, a <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/canvas">canvas</a> does the work:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">combinedStream</span><span style="color:#475569;">(</span><span style="color:#1e293b;">screenStream</span><span style="color:#475569;">, </span><span style="color:#1e293b;">webcamStream</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">canvas </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">("</span><span style="color:#0369a1;">canvas</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">canvasContext </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getContext</span><span style="color:#475569;">("</span><span style="color:#0369a1;">2d</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">width </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">1280
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">height </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">720
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">screenVideo </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">("</span><span style="color:#0369a1;">video</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">webcamVideo </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">("</span><span style="color:#0369a1;">video</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">screenVideo</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">srcObject </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">screenStream
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">webcamVideo</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">srcObject </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">webcamStream
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">screenVideo</span><span style="color:#475569;">.</span><span style="color:#1e293b;">play</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">webcamVideo</span><span style="color:#475569;">.</span><span style="color:#1e293b;">play</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#0284c7;">draw </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">canvasContext</span><span style="color:#475569;">.</span><span style="color:#1e293b;">drawImage</span><span style="color:#475569;">(</span><span style="color:#1e293b;">screenVideo</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">, </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">width</span><span style="color:#475569;">, </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">height</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">canvasContext</span><span style="color:#475569;">.</span><span style="color:#1e293b;">drawImage</span><span style="color:#475569;">(</span><span style="color:#1e293b;">webcamVideo</span><span style="color:#475569;">, </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">width </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">320</span><span style="color:#475569;">, </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">height </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">240</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">320</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">240</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(</span><span style="color:#1e293b;">draw</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">draw</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">canvas</span><span style="color:#475569;">.</span><span style="color:#1e293b;">captureStream</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">30</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span></code></pre>
<p>Lots going on here, but I think it is still followable (sneak peek: an article around canvas is coming 🤫). A canvas gets created, the screen recording gets drawn as the background and the webcam feed gets overlaid in the bottom-right corner. The <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame"><code>requestAnimationFrame</code></a> loop keeps it updating smoothly (it is an API you have read about here before). Then <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream"><code>captureStream</code></a> turns the canvas into a media stream at 30 FPS.</p>
<p>Pretty slick! 😎</p>
<h3>
<a href="#helper-methods" aria-hidden="true" class="anchor" id="helper-methods"></a>Helper methods</h3>
<p>A few small methods keep things tidy:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">startPreview</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">stream</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">previewTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">srcObject </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">stream
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">previewTarget</span><span style="color:#475569;">.</span><span style="color:#1e293b;">play</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">toggleButtons</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">startButtonTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">disabled </span><span style="font-weight:bold;color:#0369a1;">= !</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">startButtonTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">disabled
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">stopButtonTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">disabled </span><span style="font-weight:bold;color:#0369a1;">= !</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">stopButtonTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">disabled
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">clearPreview</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">previewTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">srcObject </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">cleanupStreams</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getTracks</span><span style="color:#475569;">().</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">track </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">track</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stop</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">webcamStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getTracks</span><span style="color:#475569;">().</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">track </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">track</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stop</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">screenStream </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>I like these kins of small methods where each does one thing. Preview the stream. Toggle buttons. Clear the preview. Clean up streams. This makes the main methods easier to read.</p>
<h2>
<a href="#on-organizing-stimulus-controllers" aria-hidden="true" class="anchor" id="on-organizing-stimulus-controllers"></a>On organizing Stimulus controllers</h2>
<p>Notice how the public methods (<code>start</code>, <code>stop</code>, <code>save</code>, <code>selectMode</code>) sit at the top? Then all the private methods (prefixed with <code>#</code>) below? When you open this file, you immediately see what the controller does. Start recording. Stop recording. Save recording. Select mode. The implementation details are tucked away below.</p>
<p>Compare this to alphabetically sorted methods or mixing public and private. Much harder to scan. The order matters for readability. Put the interface first, the implementation second. It is <a href="https://railsdesigner.com/proper-stimulus-controllers/">something I wrote about before</a> and more extensively in <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a>. Small organizational choices like this make your code feel more professional.</p>
<hr>
<p>And there you have it! A complete video recording feature using modern browser APIs and a well-organized Stimulus controller. No external dependencies (like the jQuery plugin that started this work), no complicated setup. Just clean JavaScript. Isn’t it pretty?</p>
<p>Give it a try and let me know how it works for you! Can just write below, no need to send a video message! 😅 Unless you start a new (succesfull) SaaS with, then please show me! ☺️</p>
]]></content>
  </entry>
  <entry>
    <title>Introducing Icons: Add any icon library to your Ruby app</title>
    <link href="https://railsdesigner.com/introducing-icons-gem/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-12T07:30:00Z</published>
    <updated>2026-02-12T07:30:00Z</updated>
    <id>https://railsdesigner.com/introducing-icons-gem/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/introducing-icons-gem/?ref=rss"><![CDATA[<p>Almost two years ago <a href="/introducing-rails-icons/">I announced Rails Icons</a>. I started that article with the notion I have no app where I do not use icons. That is still true today.</p>
<p>I also still use Rails for all my SaaS’ apps, but what if you do not? What if you use one of the many other amazing Ruby frameworks, like Hanami, Rodauth or maybe Padrino? Or what if instead of <a href="https://github.com/Rails-Designer/perron">Perron (a Rails-based SSG)</a>, you use Jekyll, Middleman or Bridgetown?</p>
<p>You cannot use the elegant way of adding SVG icons, of any of the small dozen icon libraries, in your app or site. Sad! 😞</p>
<p>So, ~210k downloads later, I extracted the core (Ruby) part from Rails Icons into its own gem: <a href="https://github.com/Rails-Designer/icons">Icons</a>. ⭐</p>
<p>So now Rails Icons core features rely on the Icons gem and only the Rails-specific parts (helper and generators) live in the Rails Icons gem itself, all while the usage of Rails Icons remains as it was.</p>
<p>This new set up allows you to either build your own layer, like Rails Icons, around Icons and package it into a gem or use it directly in your Ruby app.</p>
<p>For the latter it will look like this:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> Sync any of the supported libraries from their respective (GitHub) repository
</span><span style="color:#0c4a6e;">Icons</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Sync</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">("</span><span style="color:#0369a1;">lucide</span><span style="color:#475569;">").</span><span style="color:#0c4a6e;">now
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> And then to render an icon
</span><span style="color:#0c4a6e;">icon </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Icons</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Icon</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">name</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">check</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">library</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">lucide</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">variant</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">outline</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">arguments</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#dc2626;">class</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">text-gray-500</span><span style="color:#475569;">" })
</span><span style="color:#0c4a6e;">svg </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> icon</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">svg
</span></code></pre>
<p>If you want to build a layer around the <a href="https://github.com/Rails-Designer/icons">Icons gem</a> for the framework or SSG (if you <em>still</em> not use <a href="https://github.com/Rails-Designer/perron">Perron</a> 😅), do reach out; I am happy to help.</p>
<p>You can find the source of <a href="https://github.com/Rails-Designer/icons">Icons gem on GitHub</a>. ⭐</p>
]]></content>
  </entry>
  <entry>
    <title>Replace Turbo confirm with native dialog</title>
    <link href="https://railsdesigner.com/turboless-confirm/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-05T07:30:00Z</published>
    <updated>2026-02-05T07:30:00Z</updated>
    <id>https://railsdesigner.com/turboless-confirm/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/turboless-confirm/?ref=rss"><![CDATA[<p>Rails, when using turbo(-links), has long shipped with a built-in confirmation dialog for destructive actions. You’ve probably used it countless times:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">  post_path</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">post</span><span style="color:#475569;">),
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#075985;">method</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">delete</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">turbo_confirm</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Really delete this post?</span><span style="color:#475569;">" }
</span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>It works. It’s simple. But it’s also… well, a bit boring. The browser’s native confirm dialog is functional but not exactly pretty. And customizing it? Not really an option.</p>
<p>The default <code>turbo_confirm</code> uses the browser’s built-in confirmation dialog. It gets the job done, but you have no control over how it looks or what content it displays. Want to add formatting? Can’t do it. Want to match your app’s design? Nope. Want to include additional context or warnings? Not happening.</p>
<p>Though Turbo actually provides <a href="https://railsdesigner.com/custom-confirm-dialog/">a way to override the confirmation UI</a> and if you’re using Rails Designer’s UI components, you even get <a href="https://railsdesigner.com/docs/utilities/#custom-confirm-dialog">a few styled options out of the box</a>.</p>
<p>But today I want to explore another approach that gives you more flexibility. This is how it will look:</p>
<p><img src="/images/posts/post-delete-dialog.gif" alt="Preview of turboless confirm dialog"></p>
<p><a href="https://github.com/rails-designer-repos/turboless-dialogs">As always, the code can be found this repo</a></p>
<p>Let’s start by removing the <code>turbo_confirm</code> data attribute from your button. Change this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">  post_path</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">post</span><span style="color:#475569;">),
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#075985;">method</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">delete</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">turbo_confirm</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Really delete this post?</span><span style="color:#475569;">" }
</span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>To this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">button </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">type</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">button</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">dialog#openModal</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#</span><span style="color:#475569;">#{</span><span style="color:#0369a1;">dom_id</span><span style="color:#475569;">(</span><span style="color:#0369a1;">post</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">dialog</span><span style="color:#475569;">)}"} %&gt;
</span></code></pre>
<p>Notice it is now using <code>tag.button</code> instead of <code>button_to</code>. The <code>data-action</code> tells <a href="https://attractivejs.railsdesigner.com/">Attractive.js</a> (more on this later) to open a dialog. The <code>data-target</code> points to which dialog to open using the post’s unique ID.</p>
<p>Right after your button, add the actual dialog:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dialog </span><span style="font-weight:bold;color:#dc2626;">class</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">dialog</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> dom_id</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">post</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">dialog</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">closedby</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">any</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h4</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Really delete?</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h4</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Are you sure you want to delete post "</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">b post</span><span style="color:#475569;">.</span><span style="color:#0284c7;">name </span><span style="color:#475569;">%&gt;</span><span style="color:#0c4a6e;">"? This cannot be undone.</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">buttons</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> post_path</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">post</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">method</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">delete </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>The <code>closedby="any"</code> attribute means clicking outside the dialog or pressing Escape will close it. Inside, you have complete control over the content. Add formatting, include the post name or show warnings. Typically you want to <a href="https://railsdesigner.com/vanilla-components/">extract this into a reusable component</a>.</p>
<p>The actual delete button (that was replaced earlier) lives inside the dialog now. When clicked, it performs the real deletion. 🚮</p>
<p>To make the dialog open and close smoothly, add <a href="https://attractivejs.railsdesigner.com">Attractive.js</a> to your layout. In <code>app/views/layouts/application.html.erb</code>, add this line in your <code>&lt;head&gt;</code>:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">script </span><span style="color:#0369a1;">defer src</span><span style="color:#475569;">="</span><span style="color:#0369a1;">https://cdn.jsdelivr.net/npm/attractivejs</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">script</span><span style="color:#475569;">&gt;
</span></code></pre>
<p><a href="https://attractivejs.railsdesigner.com/#get-started">Check the docs for more installation details</a>.</p>
<p>That’s it. No custom JavaScript to write. Attractive.js provides the <code>dialog#openModal</code> action that handles opening the dialog element. It’s a JS-free JS library (yes, really).</p>
<p>Then all that is left is style the dialog however you want. See the <a href="https://github.com/rails-designer-repos/turboless-dialogs">repo</a> for a styling idea. Take especially note of the use of <code>@starting-style</code> (I explored it in <a href="https://railsdesigner.com/modern-css-overview/">this article on modern CSS</a>).</p>
<h2>
<a href="#when-to-use-which-approach" aria-hidden="true" class="anchor" id="when-to-use-which-approach"></a>When to use which approach?</h2>
<p>So when should you stick with the unstyled <code>turbo_confirm</code> versus setting up a custom dialog?</p>
<p>Use <code>turbo_confirm</code> when you need something quick and simple. If you’re building an admin interface or internal tool where aesthetics don’t matter much, the browser’s default confirm dialog is perfectly fine. It’s one line of code and it works everywhere.</p>
<p>Switch to a custom dialog when you need more control over the content and presentation. Want to include the item name in the confirmation message? Show additional context or warnings? Match your app’s design system? Go for the custom dialog approach.</p>
<p>The setup is still simple (no custom JavaScript required!) but you get complete control over the experience. ✨</p>
]]></content>
  </entry>
  <entry>
    <title>Adding user impersonation to Rails 8 authentication</title>
    <link href="https://railsdesigner.com/impersonation-for-rails-8/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-02-04T07:30:00Z</published>
    <updated>2026-02-04T07:30:00Z</updated>
    <id>https://railsdesigner.com/impersonation-for-rails-8/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/impersonation-for-rails-8/?ref=rss"><![CDATA[<p>User impersonation is a powerful feature when doing support for your SaaS. The amount of times I made annoyed customers, happy campers again because I quickly did the thing they struggled with is many. It lets you see exactly what a user sees, making it easier to debug issues or provide help.</p>
<p>This article builds on top of basic Rails 8 authentication. See all the previous commits in <a href="https://github.com/rails-designer-repos/rails-8-authentication">this repo</a>.</p>
<p>Here’s how simple it is to use once set up:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> Impersonate a user
</span><span style="color:#0c4a6e;">impersonate! User</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">find</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">42</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> Check if you're impersonating
</span><span style="color:#0c4a6e;">impersonating? </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> =&gt; true
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> Get the original user
</span><span style="color:#0c4a6e;">original_user </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> =&gt; #&lt;User id: 1&gt;
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> Stop impersonating
</span><span style="color:#0c4a6e;">unimpersonate!
</span></code></pre>
<p>The impersonation automatically expires after 1 hour (which you can adjust in the concern if needed).</p>
<p>Let’s start by adding the routes:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># config/routes.rb
</span><span style="color:#0c4a6e;">Rails.application.routes.draw do
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  resource :impersonation, only: %w[create destroy]
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>Next, update the <code>Current</code> model to handle impersonated users:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/models/current.rb
</span><span style="color:#0c4a6e;">class Current &lt; ActiveSupport::CurrentAttributes
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  attribute :session
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  delegate :user, to: :session, allow_nil: true
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  delegate :workspace, to: :user
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  attribute :session, :impersonated_user_id, :impersonated_session_id
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def user
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    impersonated_user || session&amp;.user
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  delegate :workspace, to: :user, allow_nil: true
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  private
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def impersonated_user
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    if impersonated_user_id.present?
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">      User.find_by(id: impersonated_user_id)
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    end
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>Then most of the required logic happens in the <code>Impersonatable</code> concern:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/controllers/concerns/impersonatable.rb
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">Impersonatable
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">extend </span><span style="color:#0c4a6e;">ActiveSupport</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Concern
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">included </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">    helper_method </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonating?</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">original_user</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonation_expires_at
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    before_action </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">set_impersonation_context
</span><span style="color:#0c4a6e;">    before_action </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">expire_impersonation
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  IMPERSONATION_EXPIRY </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">hour
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">impersonating?
</span><span style="color:#0c4a6e;">    session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_session_id</span><span style="color:#475569;">].</span><span style="color:#0c4a6e;">present?
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">original_user
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> impersonating?
</span><span style="color:#0c4a6e;">      Session</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">find_by</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_session_id</span><span style="color:#475569;">])</span><span style="font-weight:bold;color:#0369a1;">&amp;</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">impersonation_expires_at
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> impersonating?
</span><span style="color:#0c4a6e;">      Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">zone</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">parse</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_at</span><span style="color:#475569;">]) </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">IMPERSONATION_EXPIRY
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">set_impersonation_context
</span><span style="color:#0c4a6e;">    Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">impersonated_user_id </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_user_id</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">impersonated_session_id </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_session_id</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">expire_impersonation
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> impersonating? </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;</span><span style="color:#0c4a6e;"> impersonation_expired?
</span><span style="color:#0c4a6e;">      unimpersonate!
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">impersonate!</span><span style="color:#475569;">(</span><span style="color:#1e293b;">user</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> impersonatable?</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_session_id</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">session</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id
</span><span style="color:#0c4a6e;">      session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_user_id</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id
</span><span style="color:#0c4a6e;">      session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_at</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">unimpersonate!
</span><span style="color:#0c4a6e;">    session</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">delete</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_session_id</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    session</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">delete</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_user_id</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">impersonation_expired?
</span><span style="color:#0c4a6e;">    started_at </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">zone</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">parse</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">session</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">impersonated_at</span><span style="color:#475569;">])
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    started_at</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">blank? </span><span style="font-weight:bold;color:#0369a1;">||</span><span style="color:#0c4a6e;"> started_at</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">before?</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">IMPERSONATION_EXPIRY</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">ago</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">impersonatable?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">user</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">present?
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; !</span><span style="color:#0c4a6e;">impersonating?
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#0c4a6e;">Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id </span><span style="font-weight:bold;color:#0369a1;">!=</span><span style="color:#0c4a6e;"> user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<blockquote>
<p>[!tip]<br>
You can use <code>&amp;&amp;</code> and <code>||</code> at the start of the line since Ruby 4!</p>
</blockquote>
<p>This concern provides several safety checks. It prevents from impersonating yourself, blocks nested impersonation and automatically expires sessions after an hour.</p>
<p>Do not forget to include the concern in your <code>ApplicationController</code>.</p>
<p>The controller handling impersonation is straightforward:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/controllers/impersonations_controller.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">ImpersonationsController </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationController
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> TODO: make sure to "lock down this action"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">create
</span><span style="color:#0c4a6e;">    impersonate! User</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">find</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">user_id</span><span style="color:#475569;">])
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    redirect_to root_path
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">destroy
</span><span style="color:#0c4a6e;">    unimpersonate!
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    redirect_to root_path
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Notice the <code>TODO</code> comment? You absolutely should lock down the <code>create</code> action. But how depends on your current business logic. Other essential security measures could be:</p>
<ol>
<li>Add password confirmation as described in <a href="https://railsdesigner.com/rails-authentication-password-confirm/">this article</a>. Require administrators to re-enter their password before impersonating anyone.</li>
<li>Add user consent. Give users control by adding an <code>allow_impersonation</code> boolean to the User model.</li>
<li>Add an audit trail. At the very least, use <code>Rails.logger</code> to record who impersonated whom and when.</li>
</ol>
<p>Make sure to clean up impersonation when users log out:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/controllers/sessions_controller.rb
</span><span style="color:#0c4a6e;">class SessionsController &lt; ApplicationController
</span><span style="color:#0c4a6e;">  def destroy
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    unimpersonate! if impersonating?
</span><span style="color:#0c4a6e;">    terminate_session
</span><span style="color:#0c4a6e;">    redirect_to new_session_path
</span><span style="color:#0c4a6e;">  end
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>Check out <a href="https://github.com/rails-designer-repos/rails-8-authentication">the repo on GitHub</a> how this all could be put together in your views.</p>
<p>That’s it! But remember: this is just the foundation. Before deploying to production, implement the security measures mentioned above. Lock down who can impersonate, require password confirmation, respect user preferences and add a clear audit trail. 🔐</p>
]]></content>
  </entry>
  <entry>
    <title>Creating a link-icon custom element</title>
    <link href="https://railsdesigner.com/link-icon-custom-elements/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-01-29T07:30:00Z</published>
    <updated>2026-01-29T07:30:00Z</updated>
    <id>https://railsdesigner.com/link-icon-custom-elements/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/link-icon-custom-elements/?ref=rss"><![CDATA[<p>You know those times when you have a list of links and you want to show a nice icon next to each one? Maybe social media links in a footer, or a list of resources, or links in a bio page? You could manually add icons for each platform, but that gets tedious fast. What if the link could just show the right icon automatically?</p>
<p>That’s exactly what the <code>&lt;link-icon&gt;</code> custom element does. Pass it a URL and it figures out which icon to show. Twitter, GitHub, LinkedIn, Instagram, YouTube and a bunch more. If it doesn’t recognize the URL, it shows a generic link icon. Simple!</p>
<p>Here’s what it looks like in action:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">link-icon </span><span style="color:#0369a1;">url</span><span style="color:#475569;">="</span><span style="color:#0369a1;">https://twitter.com/username</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">link-icon</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">link-icon </span><span style="color:#0369a1;">url</span><span style="color:#475569;">="</span><span style="color:#0369a1;">https://github.com/username</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">link-icon</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">link-icon </span><span style="color:#0369a1;">url</span><span style="color:#475569;">="</span><span style="color:#0369a1;">https://railsdesigner.com</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">link-icon</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The first two show their respective platform icons. The last one shows a generic link icon. No configuration needed. Just pass the URL and you’re done.</p>
<p><a href="https://github.com/rails-designer-repos/link-icons">The code is available on GitHub</a>.</p>
<h2>
<a href="#how-it-works" aria-hidden="true" class="anchor" id="how-it-works"></a>How it works</h2>
<p>The custom element uses pattern matching to detect which platform a URL belongs to. It checks the URL against a list of patterns and returns the matching icon:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">platformPatterns </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  twitter</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">twitter</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="font-weight:bold;color:#0369a1;">|</span><span style="color:#0369a1;">x</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  facebook</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">facebook</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="font-weight:bold;color:#0369a1;">|</span><span style="color:#0369a1;">fb</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  instagram</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">instagram</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  linkedin</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">linkedin</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  github</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">github</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  youtube</span><span style="color:#475569;">: /</span><span style="color:#0369a1;">youtube</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">com</span><span style="font-weight:bold;color:#0369a1;">|</span><span style="color:#0369a1;">youtu</span><span style="font-weight:bold;color:#075985;">\.</span><span style="color:#0369a1;">be</span><span style="color:#475569;">/</span><span style="font-weight:bold;color:#dc2626;">i</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#475569;">};
</span></code></pre>
<p>(all icons come from <a href="https://phosphoricons.com/">Phosphor Icons</a>. I like that library: they’re consistent, well-designed and has vast collection of icons. It is also supported in <a href="https://github.com/Rails-Designer/rails_icons">Rails Icons</a>)</p>
<p>Notice how Twitter matches both <code>twitter.com</code> and <code>x.com</code>? Same with YouTube matching both <code>youtube.com</code> and <code>youtu.be</code>. The patterns are flexible enough to catch common variations.</p>
<p>The element stores all the icon SVGs internally. When it renders, it determines which icon to show and injects the right SVG:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#url </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">url</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"";
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">iconType </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">determineIconType</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">shadowRoot</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerHTML </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`
</span><span style="color:#0369a1;">    &lt;style&gt;
</span><span style="color:#0369a1;">      :host {
</span><span style="color:#0369a1;">        display: inline flex;
</span><span style="color:#0369a1;">        width: 1.125rem;
</span><span style="color:#0369a1;">        aspect-ratio: 1 / 1;
</span><span style="color:#0369a1;">      }
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">      .icon {
</span><span style="color:#0369a1;">        width: 100%; height: 100%;
</span><span style="color:#0369a1;">        display: flex;
</span><span style="color:#0369a1;">        align-items: center;
</span><span style="color:#0369a1;">        justify-content: center;
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">        svg {
</span><span style="color:#0369a1;">          width: 100%;
</span><span style="color:#0369a1;">          aspect-ratio: 1 / 1;
</span><span style="color:#0369a1;">          fill: currentColor;
</span><span style="color:#0369a1;">        }
</span><span style="color:#0369a1;">      }
</span><span style="color:#0369a1;">    &lt;/style&gt;
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">    &lt;div class="icon" data-icon-type="</span><span style="color:#475569;">${</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">iconType</span><span style="color:#475569;">}</span><span style="color:#0369a1;">"&gt;
</span><span style="color:#0369a1;">      </span><span style="color:#475569;">${</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">iconMarkup</span><span style="color:#475569;">()}
</span><span style="color:#0369a1;">    &lt;/div&gt;
</span><span style="color:#0369a1;">  </span><span style="color:#475569;">`;
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>#determineIconType</code> method loops through the patterns and returns the first match:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">determineIconType</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#url</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#475569;">"</span><span style="color:#0369a1;">default</span><span style="color:#475569;">";
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">[</span><span style="color:#1e293b;">platform</span><span style="color:#475569;">, </span><span style="color:#1e293b;">pattern</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">of </span><span style="color:#0c4a6e;">Object</span><span style="color:#475569;">.</span><span style="color:#1e293b;">entries</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">platformPatterns</span><span style="color:#475569;">)) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">pattern</span><span style="color:#475569;">.</span><span style="color:#1e293b;">test</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#url</span><span style="color:#475569;">)) </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">platform</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#475569;">"</span><span style="color:#0369a1;">default</span><span style="color:#475569;">";
</span><span style="color:#475569;">}
</span></code></pre>
<p>If no pattern matches, it returns <code>"default"</code> which shows the generic link icon.</p>
<h2>
<a href="#using-shadow-dom-for-encapsulation" aria-hidden="true" class="anchor" id="using-shadow-dom-for-encapsulation"></a>Using Shadow DOM for encapsulation</h2>
<p>The element uses Shadow DOM to keep its styles isolated. Say what? I think this is one of the coolest features of custom elements: the styles you write inside the Shadow DOM don’t leak out to the rest of your page and styles from your page don’t leak in. See that <code>:host</code> selector in the styles? That targets the custom element (<code>this.element</code> if you are familiar with Stimulus) itself. You can style the element from the outside (like setting its width), but the internal structure (the <code>.icon</code> div and the SVG) is completely protected (you can optionally “open it up”; will write about that later). Your global CSS won’t accidentally mess with it. So this means that if you have a <code>.icon</code> class in your main CSS and a <code>.icon</code> class in the Shadow DOM, they won’t conflict. Pretty neat!</p>
<p>The Shadow DOM is created in the constructor:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">constructor</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">super</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">attachShadow</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">mode</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">open</span><span style="color:#475569;">" });
</span><span style="color:#475569;">}
</span></code></pre>
<p>That <code>mode: "open"</code> means you can access the shadow root from JavaScript if needed (which is almost always 😅). But for most cases, you just set it up once and forget about it.</p>
<h2>
<a href="#updates-on-the-lfy" aria-hidden="true" class="anchor" id="updates-on-the-lfy"></a>Updates on-the-lfy</h2>
<p>The element observes the <code>url</code> attribute. If you change it, the icon updates automatically:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static get observedAttributes</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#475569;">["</span><span style="color:#0369a1;">url</span><span style="color:#475569;">"];
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">attributeChangedCallback</span><span style="color:#475569;">(</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">oldValue</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">name </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">url</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">oldValue </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#1e293b;">newValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>This means you can update the URL dynamically and the icon will change. Useful if you’re building something interactive where links change based on user input.</p>
<p>You can also set the URL via JavaScript with a Stimulus controller:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/controllers/profile_controller.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">targets </span><span style="color:#0c4a6e;">= ["icon"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">updateIcon</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">iconTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">url </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">profile</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">link-icon </span><span style="color:#0369a1;">data-profile-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">icon</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">link-icon</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">input </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">url</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">input-&gt;profile#updateIcon</span><span style="color:#475569;">"&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The setter updates the attribute, which triggers the <code>attributeChangedCallback</code>, which re-renders the element. Everything stays in sync.</p>
<h2>
<a href="#why-custom-elements" aria-hidden="true" class="anchor" id="why-custom-elements"></a>Why custom elements?</h2>
<p>You might wonder why use a custom element instead of a helper method or a component. It’s reusable across any framework or no framework at all. Drop the JavaScript file into any project and it works. But, more interestingly, it’s reactive. Change the URL and the icon updates. That’s impossible to do with a helper method/component alone.</p>
<p>If you’re new to custom elements, I wrote about <a href="https://railsdesigner.com/custom-elements/">custom elements</a> <a href="https://railsdesigner.com/custom-element-inline-edit/">before</a> before. Check them out to read more.</p>
<h2>
<a href="#setting-it-up" aria-hidden="true" class="anchor" id="setting-it-up"></a>Setting it up</h2>
<p>To use this in your Rails app, add the JavaScript file to <code>app/javascript/components/link-icon.js</code> (the commit shows the full code). Then import it in your <code>application.js</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components/link-icon</span><span style="color:#475569;">"
</span></code></pre>
<p>If you’re using importmap (like the example in the commit), pin the components directory:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> config/importmap.rb
</span><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/components</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components</span><span style="color:#475569;">"
</span></code></pre>
<p>Then just use <code>&lt;link-icon&gt;</code> in your views. That’s it!</p>
<hr>
<p>Pretty handy, right?</p>
<p>Let me know if you try it or have questions below!</p>
]]></content>
  </entry>
  <entry>
    <title>CSS Counters: auto-update list numbers without JavaScript</title>
    <link href="https://railsdesigner.com/css-counters/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-01-26T07:30:00Z</published>
    <updated>2026-01-26T07:30:00Z</updated>
    <id>https://railsdesigner.com/css-counters/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/css-counters/?ref=rss"><![CDATA[<p>This is a quick-tip about a CSS feature. It is the kind of feature most developer (and LLM’s) would use JavaScript for. With CSS you get this for free. Knowing about it, makes you a better developer! 🏆</p>
<p>Recently I <a href="https://railsdesigner.com/saas/month/">helped build a new product</a> that involved pages with content in a specific order.</p>
<p>For the ordering I used the nice <a href="https://github.com/brendon/positioning">positioning gem</a>. This gives sequentially numbering (1, 2, 3 and so on).</p>
<p>So you can display them like this:</p>
<p><img src="/images/posts/css-counters-seq-numbers.jpg" alt=""></p>
<p>The ERB is something simple like this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> locals: (page:) %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">li </span><span style="color:#0369a1;">draggable data-reposition-id-value</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> page</span><span style="color:#475569;">.</span><span style="color:#0369a1;">id </span><span style="color:#475569;">%&gt;"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">span page</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">position </span><span style="font-weight:bold;color:#dc2626;">class</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0284c7;">p</span><span style="color:#0c4a6e;"> page</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>(<code>reposition</code> is coming from the <a href="https://railsdesigner.com/kanban-rails-hotwire/">Kanban article</a>)</p>
<p>But, of course, the pages needed to be sortable. So I used the code from the article <a href="https://railsdesigner.com/kanban-rails-hotwire/">Create a Kanban board with Rails and Hotwire</a> (a routing concern, Stimulus- and Rails controller and some additions to the HTML 😎) and, yay, in no-time, pages could be sorted in a custom way:</p>
<p><img src="/images/posts/css-counters-incorrect-seq-numbers.jpg" alt=""></p>
<p>But wait, the numbers are now incorrect. I can refresh the page, or maybe used <a href="/turbo-drive-frame-stream-morph/#turbo-morph">morph</a> and the numbers will match up again, but this is of course not acceptable.</p>
<p>So how’d you do this? If I hadn’t already hinted at CSS above, would you reach for JavaScript? Extend the reposition Stimulus controller? Or add a dedicated controller for this purpose? It would likely be only a 10-line controller, so why not?</p>
<p>Because CSS has a feature that can help you with this: <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Counter_styles/Using_counters">CSS counters</a>.</p>
<p>CSS counters automatically track and display numbers based on the DOM order. Not the database order. Add this to the wrapping ul element <code>[counter-reset:page-num]</code> and update the page partial like this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> locals: (page:) %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">li </span><span style="color:#0369a1;">draggable data-reposition-id-value</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> page</span><span style="color:#475569;">.</span><span style="color:#0369a1;">id </span><span style="color:#475569;">%&gt;"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">span </span><span style="font-weight:bold;color:#dc2626;">class</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">before:content-[counter(page-num)] [counter-increment:page-num]</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0284c7;">p</span><span style="color:#0c4a6e;"> page</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>If you use <del>real</del>vanilla CSS, you can use:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">ul </span><span style="color:#475569;">{ </span><span style="color:#0c4a6e;">counter-reset</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">page-num</span><span style="color:#475569;">; }
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">li span</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">before </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  content</span><span style="color:#475569;">: </span><span style="color:#0284c7;">counter</span><span style="color:#475569;">(</span><span style="color:#0369a1;">page-num</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  counter-increment</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">page-num</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span></code></pre>
<p>So how do CSS counters work? You initialize a counter with <code>counter-reset</code> on a parent element. Then each time you use <code>counter-increment</code>, it bumps up by one. Finally, <code>counter()</code> displays the current value. The browser handles all the counting automatically as elements move around in the DOM.</p>
<p>Why not use <code>ol&gt;li</code> and style the <code>::marker</code> pseudo-selector? Great question! The <code>::marker</code> pseudo-element is quite limited in what you can style. You can change colors, fonts and the content itself. But that’s about it. No backgrounds, borders, padding or positioning. If you need more control over the visual styling of your numbers, like adding backgrounds, custom spacing or complex layouts (like I needed in above product, but not showed), you’ll need to use CSS counters with regular elements instead.</p>
<p>And that is it. Yet another powerful CSS feature you can use instead of JavaScript! ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Building optimistic UI in Rails powered by Turbo</title>
    <link href="https://railsdesigner.com/turbo-powered-optimistic-ui/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-01-22T07:30:00Z</published>
    <updated>2026-01-22T07:30:00Z</updated>
    <id>https://railsdesigner.com/turbo-powered-optimistic-ui/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/turbo-powered-optimistic-ui/?ref=rss"><![CDATA[<p>A while back I showed you how to build <a href="https://railsdesigner.com/custom-elements/">optimistic UI using custom elements</a>. It worked great! And you thought too, it was shared far and wide (it was <del>read</del>seen by many thousands!).</p>
<p>Something like this (no, really, this is not the same gif as the one from the custom elements article):<br>
<img src="/images/posts/turbo-powered-optimistic-ui.gif" alt=""></p>
<p>But something bugged me. The custom element wrapper felt like extra ceremony. What if I could get the same instant feedback without the extra markup? Just a form, some data attributes (Rails developers ❤️ data attributes) and a sprinkle of (custom) JavaScript? 😊</p>
<p>Guess what? You can! And it is even simpler. 🎉</p>
<p><a href="https://github.com/rails-designer-repos/custom-elements">The code is available on GitHub</a> (see the last commit).</p>
<p>The custom element approach looked like this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">optimistic-form</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">form </span><span style="color:#0369a1;">action</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> messages_path </span><span style="color:#475569;">%&gt;" </span><span style="color:#0369a1;">method</span><span style="color:#475569;">="</span><span style="color:#0369a1;">post</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> text_area_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">message[content]</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">placeholder</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Write a message…</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">required</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> submit_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Send</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">form</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">template </span><span style="color:#0369a1;">response</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#0c4a6e;">Message</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">content</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"", </span><span style="font-weight:bold;color:#075985;">created_at</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current</span><span style="color:#475569;">) %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">optimistic-form</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>That <code>&lt;optimistic-form&gt;</code> wrapper is extra markup. The template lives inside it. You need to define the custom element, register it, manage its lifecycle. Not too bad, but it is not exactly lightweight.</p>
<p>What if you could just mark the form itself as optimistic?</p>
<h2>
<a href="#adding-data-attributes" aria-hidden="true" class="anchor" id="adding-data-attributes"></a>Adding data attributes</h2>
<p>Here is what the new version looks like:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">message</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">              </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">                </span><span style="font-weight:bold;color:#075985;">optimistic</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">                </span><span style="font-weight:bold;color:#075985;">optimistic_target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">messages</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">                </span><span style="font-weight:bold;color:#075985;">optimistic_template</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">message-template</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">                </span><span style="font-weight:bold;color:#075985;">optimistic_position</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">prepend</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">              </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">text_area </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">content</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">placeholder</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Write a message…</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">required</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">submit </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Send</span><span style="color:#475569;">" %&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">template </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">message-template</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#0c4a6e;">Message</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">content</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"", </span><span style="font-weight:bold;color:#075985;">created_at</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current</span><span style="color:#475569;">) %&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">messages</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#475569;">@</span><span style="color:#1e293b;">messages </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Just a regular form with some data attributes. The template lives separately (you can put it anywhere). Everything is explicit through data attributes.</p>
<p>The JavaScript listens for Turbo’s <code>submit-start</code> event on any form marked with <code>data-optimistic="true"</code>. When fired, it clones the template, populates it with the form data and then inserts it into the target. Cool, right?!</p>
<p>So how is this version working? Just a plain, old javascript class, really!</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/optimistic_form.js
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">OptimisticForm </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">start</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">turbo:submit-start</span><span style="color:#475569;">", (</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">startSubmit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">turbo:submit-end</span><span style="color:#475569;">", (</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">endSubmit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">startSubmit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">isOptimistic</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">checkValidity</span><span style="color:#475569;">()) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">formData </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">FormData</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">build</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">insert</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">into</span><span style="color:#475569;">: </span><span style="color:#1e293b;">form </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">endSubmit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">isOptimistic</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0284c7;">reset</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">isOptimistic</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimistic </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">build</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="color:#1e293b;">with</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">template </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findTemplate</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">template</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">.</span><span style="color:#0284c7;">cloneNode</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">firstElementChild
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">populate</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">element
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">findTemplate</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">selector </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimisticTemplate
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getElementById</span><span style="color:#475569;">(</span><span style="color:#1e293b;">selector</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">populate</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#1e293b;">with</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">[</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">value</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">of </span><span style="color:#1e293b;">formData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">entries</span><span style="color:#475569;">()) {
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">(`</span><span style="color:#0369a1;">[data-field="</span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">"]</span><span style="color:#475569;">`)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">) </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">value
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">insert</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#1e293b;">into</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">form </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findTarget</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">position </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimisticPosition </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">append</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">position </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">prepend</span><span style="color:#475569;">") {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">prepend</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">append</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">findTarget</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">selector </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimisticTarget
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getElementById</span><span style="color:#475569;">(</span><span style="color:#1e293b;">selector</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">OptimisticForm</span><span style="color:#475569;">.</span><span style="color:#1e293b;">start</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default </span><span style="color:#1e293b;">OptimisticForm
</span></code></pre>
<p>(do not forget to import it in your entrypoint)</p>
<p>So how does class work? It is entirely static. No instances needed (if that sounds foreign to you, I suggest <a href="https://javascriptforrails.com/">checking out JavaScript for Rails Developers</a>). It sets up two listeners for Turbo-powered events: <code>turbo:submit-start</code> and <code>turbo:submit-end</code>.</p>
<p>When a form submits, check if it has <code>data-optimistic="true"</code>. If not, ignore it. If yes, grab the form data, clone the template, populate the fields and insert it into the target.</p>
<p>The <code>#build</code> method does this heavy lifting:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">build</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">template </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findTemplate</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">template</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">.</span><span style="color:#0284c7;">cloneNode</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">firstElementChild
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">populate</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">element
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>#populate</code> method loops through the form data and updates any element with a matching <code>data-field</code> attribute:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">populate</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">[</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">value</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">of </span><span style="color:#1e293b;">formData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">entries</span><span style="color:#475569;">()) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">(`</span><span style="color:#0369a1;">[data-field="</span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">"]</span><span style="color:#475569;">`)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">) </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">value
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>This is the same technique from the custom element version. Your partial needs <code>data-field</code> attributes on the elements you want to populate:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">article </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">message</span><span style="color:#475569;">" </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> dom_id</span><span style="color:#475569;">(</span><span style="color:#0369a1;">message</span><span style="color:#475569;">) %&gt;"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#0369a1;">data-field</span><span style="color:#475569;">="</span><span style="color:#0369a1;">message[content]</span><span style="color:#475569;">"&gt;&lt;%=</span><span style="color:#0c4a6e;"> message</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content </span><span style="color:#475569;">%&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">small</span><span style="color:#475569;">&gt;&lt;%=</span><span style="color:#0c4a6e;"> message</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">created_at</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">strftime</span><span style="color:#475569;">("</span><span style="font-weight:bold;color:#075985;">%Y</span><span style="color:#0369a1;">/</span><span style="font-weight:bold;color:#075985;">%m</span><span style="color:#0369a1;">/</span><span style="font-weight:bold;color:#075985;">%d</span><span style="color:#475569;">") %&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">small</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">article</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The <code>#insert</code> method handles positioning. You can prepend or append (default):</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">insert</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">into</span><span style="color:#475569;">: </span><span style="color:#1e293b;">form </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findTarget</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">position </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimisticPosition </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">append</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">position </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">prepend</span><span style="color:#475569;">") {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">prepend</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">append</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>Then there is the <code>#endSubmit</code> method to reset the form after submission completes. This gives instant feedback. The user types a message, hits send, the message appears in the list and the form clears. All before the server responds. ⚡</p>
<p>You could handle this with a Turbo Stream instead, but keeping it in the JavaScript feels cleaner. It is part of the optimistic UX, so it belongs with the optimistic code.</p>
<h2>
<a href="#on-writing-javascript-well" aria-hidden="true" class="anchor" id="on-writing-javascript-well"></a>On writing JavaScript well</h2>
<p>One thing I really like about this implementation is the named parameters:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">build</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">insert</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">into</span><span style="color:#475569;">: </span><span style="color:#1e293b;">form </span><span style="color:#475569;">})
</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">populate</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">})
</span></code></pre>
<p>These read like real sentences. 🤓 “Build an element with form data.” “Insert element into form.” “Populate element with form data.” JavaScript destructuring makes this possible:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">build</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> …
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>with: formData</code> syntax renames the parameter from <code>with</code> (a reserved word) to <code>formData</code> inside the function. It is a small detail but it makes the code much more readable.</p>
<p>It is something I write about in my by <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a>.</p>
<h3>
<a href="#why-static-methods" aria-hidden="true" class="anchor" id="why-static-methods"></a>Why static methods?</h3>
<p>You might wonder why everything is static. Why not create instances? You could do this:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static start</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">turbo:submit-start</span><span style="color:#475569;">", (</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">optimistic </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">OptimisticForm</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">constructor</span><span style="color:#475569;">(</span><span style="color:#1e293b;">form</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">form
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> … handle submission
</span><span style="color:#475569;">}
</span></code></pre>
<p>But for this use case, instances add complexity without much benefit. We are not managing state. We are not tracking multiple submissions. We are just doing some DOM manipulation and moving on.</p>
<p>Static methods keep it simple. The class is really just a namespace for related functions. And that is okay! Not everything needs to be an instance.</p>
<hr>
<p>The use cases are the same as the custom element’s one, but this solution feels more Rails-like: add <code>data-optimistic="true"</code> to your form, point it at a template and target and you are off to the races.</p>
<p>Pretty cool, right? Let me know below if you try it or have questions! ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Nested forms without `accepts_nested_attributes_for` in Rails</title>
    <link href="https://railsdesigner.com/nested-forms-without-accepts-nested-attributes/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-01-15T07:30:00Z</published>
    <updated>2026-01-15T07:30:00Z</updated>
    <id>https://railsdesigner.com/nested-forms-without-accepts-nested-attributes/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/nested-forms-without-accepts-nested-attributes/?ref=rss"><![CDATA[<p><a href="https://railsdesigner.com/saas/month/">Recently I had to build</a> <a href="https://chirpform.com/">a form builder</a> again. You know the one: update some attributes for the form and then a customizable set of form fields, like text fields, email fields and text areas. All with their own validations, settings and so on. It turned out like this:</p>
<p><img src="/images/posts/chirpform_formbuilder.gif" alt=""></p>
<p>Initially I thought of building it using Rails’ <code>accepts_nested_attributes_for</code> (like <a href="https://railsdesigner.com/rails-nested-forms-with-turbo/">I wrote</a> <a href="https://railsdesigner.com/rails-nested-form-with-stimulus/">about here</a>), but I quickly opted out of that and instead had a simpler idea for it. Some ideas are so simple that I feel almost embarrassed to publish about it. But over the years I’ve found that the most simple ideas will still delight plenty of people.</p>
<p>So here is <strong>a way to have “nested forms” in Rails without <code>accepts_nested_attributes_for</code></strong>.</p>
<p>As often is the case, the <a href="https://github.com/rails-designer-repos/no-nested-forms">code can be found on GitHub</a>. It doesn’t look as polished as the example in the Gif above, but it uses essentially the same logic under the hood.</p>
<p>Start with the basic models. A <code>Form</code> has many <code>Field</code>s. Each field uses Single Table Inheritance (STI) to handle different field types:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/models/form.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Form </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationRecord
</span><span style="color:#0c4a6e;">  has_many </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">fields</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">class_name</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Form::Field</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/models/form/field.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Form</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Field </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationRecord
</span><span style="color:#0c4a6e;">  belongs_to </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  TYPES </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">Form::Field::TextArea Form::Field::TextField</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#1e293b;">self</span><span style="color:#475569;">.</span><span style="color:#0284c7;">model_name</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">ActiveModel</span><span style="color:#475569;">:</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">Name</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="color:#1e293b;">self</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Field</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">to_partial_path</span><span style="color:#0c4a6e;"> = "</span><span style="color:#1e293b;">forms</span><span style="color:#0c4a6e;">/</span><span style="color:#1e293b;">field</span><span style="color:#0c4a6e;">"
</span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/models/form/field/text_field.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Form</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Field</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">TextField </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Form</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Field
</span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/models/form/field/text_area.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Form</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Field</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">TextArea </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Form</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Field
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>The <code>model_name</code> override keeps URLs clean (<code>/forms/1/fields</code> instead of <code>/forms/1/form_fields</code>). The <code>to_partial_path</code> allows to render all field types with a single partial.</p>
<p>Next, the migrations:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> db/migrate/20251209080000_create_forms.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">CreateForms </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ActiveRecord</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Migration</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#d97706;">8.1</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">change
</span><span style="color:#0c4a6e;">    create_table </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">forms </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">t</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">string </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">name
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> db/migrate/20251209080030_create_form_fields.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">CreateFormFields </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ActiveRecord</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Migration</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#d97706;">8.1</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">change
</span><span style="color:#0c4a6e;">    create_table </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form_fields </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">t</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">belongs_to </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">null</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">false</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">foreign_key</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">string </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">type
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">string </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">label
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      t</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">timestamps
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Run the migrations and seed a form:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> db/seeds.rb
</span><span style="color:#0c4a6e;">Form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create </span><span style="font-weight:bold;color:#075985;">name</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">My first form</span><span style="color:#475569;">"
</span></code></pre>
<p>Easy does it!</p>
<p>Now onto building the edit page. This is where it happens. Instead of one big form with nested attributes, it will be separate forms for the parent and each field. Say what?! 🤯</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/forms/edit.html.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> dom_id</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">name </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">text_field </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">name</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#</span><span style="color:#475569;">#{</span><span style="color:#0369a1;">dom_id</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form</span><span style="color:#475569;">)}", </span><span style="font-weight:bold;color:#075985;">submit_delay</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1500</span><span style="color:#475569;">}%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">submit </span><span style="font-weight:bold;color:#075985;">hidden</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">ol </span><span style="color:#0284c7;">render</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">fields</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> dom_id</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">fields</span><span style="color:#475569;">) %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    Add new field:
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;% </span><span style="color:#0c4a6e;">Form</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Field</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">TYPES</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">each </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">type</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> button_to type</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">delete_prefix</span><span style="color:#475569;">("</span><span style="color:#0369a1;">Form::Field::</span><span style="color:#475569;">"),</span><span style="color:#0c4a6e;"> form_fields_path</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">form</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">method</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">post</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">params</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">field</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">type</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> type </span><span style="color:#475569;">} } %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The form at the top handles the parent model. Simple. Below that are all existing fields rendered. At the very bottom just some buttons to add new fields of different types.</p>
<p>Notice the <code>data-action</code> attribute on the form’s name text field? That’s <a href="https://attractivejs.railsdesigner.com/">Attractive.js</a> doing its thing. It’s a new library I released recently that lets you skip writing lots of typical Stimulus controllers (works great with <a href="https://perron.railsdesigner.com/">your SSG too</a>). Here it automatically submits the form after you stop typing for 1.5 seconds. Pretty neat! 🎯</p>
<p>This auto-save feature is what makes this whole technique possible. Without it, each field would need its own submit button, which would be weird and clunky.</p>
<p>Make sure to Attractive.js to your layout:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">&lt;%# app/views/layouts/application.html.erb %&gt;
</span><span style="color:#0c4a6e;">&lt;head&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;script defer src="https://cdn.jsdelivr.net/npm/attractivejs"&gt;&lt;/script&gt;
</span><span style="color:#0c4a6e;">&lt;/head&gt;
</span></code></pre>
<p>Onto rendering the form fields. Each field gets rendered with this partial:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/forms/_field.html.erb %&gt;
</span><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> locals: (field:, form: field.form, open: false) %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">li </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> dom_id</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">field</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">details </span><span style="color:#475569;">&lt;%= "</span><span style="color:#0369a1;">open</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#0284c7;">open </span><span style="color:#475569;">%&gt;&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">summary</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Unnamed</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">summary</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> field</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">url</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> form_field_path</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">,</span><span style="color:#0c4a6e;"> field</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> dom_id</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">field</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">label </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">text_field </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">label</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#</span><span style="color:#475569;">#{</span><span style="color:#0369a1;">dom_id</span><span style="color:#475569;">(</span><span style="color:#0369a1;">field</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">form</span><span style="color:#475569;">)}", </span><span style="font-weight:bold;color:#075985;">submit_delay</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">1500</span><span style="color:#475569;">"} %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">submit </span><span style="font-weight:bold;color:#075985;">hidden</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">details</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Each field has its own form. That’s the key insight here. No nested attributes. No complex params parsing. Just simple forms that each know how to save themselves.</p>
<p>The <code>&lt;details&gt;</code> element gives us a collapsible UI for free. When you add a new field it opens automatically (via the <code>open</code> local). The summary shows the field label, or “Unnamed” if it’s fresh (it is not required, it is just to mimic the Chirp Form example from above).</p>
<p>Then just wire the views and models up with basic controllers, the routes and create turbo stream responses to update in place and you have nested forms in Rails without a headache! Huzzah! 🎉</p>
<h2>
<a href="#why-this-works" aria-hidden="true" class="anchor" id="why-this-works"></a>Why this works</h2>
<p>This approach works because each form is independent. The parent form saves the parent model. Each child form saves its own child model. No coordination needed. No complex params parsing. No <code>accepts_nested_attributes_for</code> set up (that you always mess up multiple times, admit it).</p>
<p>The auto-save feature really makes this work well. You type, you wait a moment and it saves. Boom! Sometimes the best solution is the one that doesn’t try to be too clever. 😊</p>
<hr>
<p>Want to give it a try and see if this approach works for your next form builder?</p>
]]></content>
  </entry>
  <entry>
    <title>Use native dialog with Turbo (and no extra JavaScript)</title>
    <link href="https://railsdesigner.com/dialog-turboframe/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-01-08T07:30:00Z</published>
    <updated>2026-01-08T07:30:00Z</updated>
    <id>https://railsdesigner.com/dialog-turboframe/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/dialog-turboframe/?ref=rss"><![CDATA[<p>Building modals and sliders previously meant reaching for a Stimulus controller. But what if you could create them without writing any JavaScript at all? Like none, zip, nada, nothing. Something that looks like this:</p>
<p><img src="/images/posts/turbo-dialog.gif" alt=""></p>
<p>This article shows how to use the native <code>&lt;dialog&gt;</code> element combined with <a href="https://attractivejs.railsdesigner.com">Attractive.js</a> (a JS-free JS library I published recently) to build modals and sliders that work together with Turbo Frames. No custom JavaScript required. 🤯</p>
<p>As always <a href="https://github.com/rails-designer-repos/turbo-dialog">the code can be found on GitHub</a>.</p>
<h2>
<a href="#the-basics" aria-hidden="true" class="anchor" id="the-basics"></a>The basics</h2>
<p>The foundation is a single <code>&lt;dialog&gt;</code> element in your layout. Start by adding it to your <code>application.html.erb</code>:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">dialog
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">overlay</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">closedby</span><span style="color:#475569;">="</span><span style="color:#0369a1;">any</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="
</span><span style="color:#0369a1;">    px-3 py-4 max-w-md w-full
</span><span style="color:#0369a1;">    m-auto rounded-lg
</span><span style="color:#0369a1;">    opacity-100 scale-100
</span><span style="color:#0369a1;">    shadow-2xl
</span><span style="color:#0369a1;">    starting:opacity-0 starting:scale-95
</span><span style="color:#0369a1;">    transition-all duration-300
</span><span style="color:#0369a1;">    backdrop:bg-black/50 backdrop:backdrop-blur-sm
</span><span style="color:#0369a1;">  </span><span style="color:#475569;">"
</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">turbo_frame </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">dialog</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The <code>closedby="any"</code> attribute means clicking outside the dialog or pressing escape will close it. The Turbo Frame inside provides the content target. The CSS classes handle the fade and scale animation using the <code>starting:</code> pseudo-class. This is using Tailwind CSS, but you can of course use <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@starting-style">@starting-style</a>.</p>
<p>Now add Attractive.js to your JavaScript. Import it in <code>app/javascript/application.js</code> (you can install however else you want; <a href="https://attractivejs.railsdesigner.com/#get-started">see the docs</a>:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
</span><span style="color:#0c4a6e;"> import "@hotwired/turbo-rails"
</span><span style="color:#0c4a6e;"> import "controllers"
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">import attractivejs from "https://esm.sh/attractivejs";
</span></code></pre>
<p>Attractive.js provides the <code>dialog#openModal</code> action that opens the dialog element. No custom controller needed.</p>
<p>You can then use it like this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Show modal</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> modal_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">dialog#openModal</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#overlay</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">turbo_frame</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">} %&gt;
</span></code></pre>
<p>The <code>data-action</code> attribute tells Attractive.js to open the dialog. The <code>data-target</code> points to the dialog element. The <code>data-turbo-frame</code> tells Turbo to load the response into the modal frame. Simple!</p>
<p>Click the link and the modal opens. Click outside or press escape and it closes.</p>
<h2>
<a href="#supporting-both-modals-and-sliders" aria-hidden="true" class="anchor" id="supporting-both-modals-and-sliders"></a>Supporting both modals and sliders</h2>
<p>Now extend this to support both modals and sliders using the same dialog element. The trick is using a <code>type</code> attribute to differentiate between them.</p>
<p>First update the dialog styles to handle both types:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">dialog
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">overlay</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">closedby</span><span style="color:#475569;">="</span><span style="color:#0369a1;">any</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="
</span><span style="color:#0369a1;">    px-3 py-4 max-w-md w-full
</span><span style="color:#0369a1;">    opacity-100 scale-100 translate-x-0 translate-y-0
</span><span style="color:#0369a1;">    shadow-2xl
</span><span style="color:#0369a1;">    starting:opacity-0
</span><span style="color:#0369a1;">    transition-all duration-300
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">    /* Modal-specific */
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:m-auto
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:rounded-lg
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:starting:scale-95
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:backdrop:bg-black/50 [&amp;[type=modal]]:backdrop:backdrop-blur-sm
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:max-sm:mb-0 [&amp;[type=modal]]:max-sm:rounded-b-none
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:max-sm:starting:translate-y-full
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:max-sm:starting:scale-100
</span><span style="color:#0369a1;">    [&amp;[type=modal]]:sm:my-auto
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">    /* Slider-specific */
</span><span style="color:#0369a1;">    [&amp;[type=slider]]:m-0 [&amp;[type=slider]]:ml-auto
</span><span style="color:#0369a1;">    [&amp;[type=slider]]:h-screen [&amp;[type=slider]]:max-h-none [&amp;[type=slider]]:max-w-sm
</span><span style="color:#0369a1;">    [&amp;[type=slider]]:rounded-l-lg
</span><span style="color:#0369a1;">    [&amp;[type=slider]]:starting:translate-x-full
</span><span style="color:#0369a1;">  </span><span style="color:#475569;">"
</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">turbo_frame </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">dialog</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The <code>[&amp;[type=modal]]</code> and <code>[&amp;[type=slider]]</code> selectors apply different styles based on the <code>type</code> attribute. Modals center themselves and scale in. Sliders stick to the right edge and slide in from the side.</p>
<p>On mobile, modals slide up from the bottom instead of scaling. Sliders maintain their slide-in behavior across all screen sizes. Just as seen in the Gif above.</p>
<p>Now update the links to set the type attribute before opening:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Show modal</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> modal_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">addAttribute#type=modal dialog#openModal</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#overlay</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">turbo_frame</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">} %&gt;
</span></code></pre>
<p>The <code>addAttribute#type=modal</code> action from Attractive.js sets the <code>type</code> attribute on the dialog before opening it. This triggers the modal-specific styles. The order is important as you want the type to be set first, otherwise styles from another type might leak through.</p>
<p>Add another modal endpoint to show it works with multiple modals:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">another_modal
</span><span style="color:#0c4a6e;">  render </span><span style="font-weight:bold;color:#075985;">html</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> helpers</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">turbo_frame</span><span style="color:#475569;">("</span><span style="color:#0369a1;">Another modal content</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">)
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>And the corresponding link:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Show another modal</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> another_modal_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">addAttribute#type=modal dialog#openModal</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#overlay</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">turbo_frame</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">} %&gt;
</span></code></pre>
<p>Now add a slider endpoint:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">slider
</span><span style="color:#0c4a6e;">  render </span><span style="font-weight:bold;color:#075985;">html</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> helpers</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">tag</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">turbo_frame</span><span style="color:#475569;">("</span><span style="color:#0369a1;">Slider content</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">)
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>With its link:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Show slider</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> slider_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{</span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">addAttribute#type=slider dialog#openModal</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#overlay</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">turbo_frame</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">modal</span><span style="color:#475569;">} %&gt;
</span></code></pre>
<p>The only difference is <code>type=slider</code> instead of <code>type=modal</code>. Same dialog element, same Turbo Frame, different presentation. The CSS handles everything else.</p>
<hr>
<p>Browsers get more and more powerful features, like <code>dialog</code>. Using these keeps your code simple and easier to maintain. The native dialog element provides accessibility features like focus trapping and escape key handling. There is also the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API">Invoke Commands API</a> that means even some of the <a href="https://github.com/rails-designer/attractivejs">Attractive.js</a> interactivity without would not be needed. Although setting the Turbo Frame <code>src</code> attribute would still be needed. ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>The Best of 2025 from Rails Designer</title>
    <link href="https://railsdesigner.com/best-of-2025/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-12-25T07:15:00Z</published>
    <updated>2025-12-25T07:15:00Z</updated>
    <id>https://railsdesigner.com/best-of-2025/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/best-of-2025/?ref=rss"><![CDATA[<p>It’s (for a few in this world) Christmas day. And also, again for a bit more than a few, the end of 2025. That means things slow down and thus a perfect opportunity to read up on what was happening and published on Rails Designer. <a href="https://railsdesigner.com/best-of-2024/">Last year I also published a “best of” list (for 2024)</a>. And looking at it, things have not slowed down much. Next to <a href="https://railsdesigner.com/components/">Rails Designer’s UI Components Library</a> being steady (about ~1.2k total in 2025), I also published a few new OSS projects (some on this list will be officially launched next year; so you read about them first here; unless you read my newsletter—those were truly the first! 🤫).</p>
<ul>
<li>
<a href="https://github.com/Rails-Designer/courrier/">Courrier</a> is one I am really happy about (and use in all my projects) but has not picked up yet.</li>
<li>
<a href="https://github.com/Rails-Designer/perron/">Perron</a> is one I have high hopes for. A Rails-based SSG with all the (SaaS) marketing features baked in. What’s not to love?</li>
<li>
<a href="https://github.com/Rails-Designer/attractivejs/">Attractive.js</a> seems to hit off (modestly, for now) and it is one I am really excited about too.</li>
<li>
<a href="https://github.com/Rails-Designer/requestkit">Requestkit</a> a useful, little tool to receive/send webhooks/API request; a cool little kit in your development toolbelt.</li>
<li>
<a href="https://github.com/Rails-Designer/fuik">Fuik</a> allows you to catch webhooks from any provider and run with it. Will be announced in 2026. 🤫</li>
<li>
<a href="https://github.com/Rails-Designer/kern">Kern</a> is a Rails engine for a SaaS foundation. Add gem, run migrations and you are ready to focus on your SaaS’ business logic. Will also publicly launch this in 2026. 🫢</li>
</ul>
<p>Both Perron and Attractive.js I am truly excited about. They also work really great together!</p>
<p>All of the above have not reached the 1.0.0 release. That is on the list for 2026. I like new OSS put to work first (by you and me), before tagging a 1.0 release. Published in 2024, <a href="https://github.com/Rails-Designer/rails_icons/">Rails Icons</a> really grew big in 2025. It sees about 15k downloads every month. Not sure why, but it is cool to see.</p>
<p>I hope to see more contributors to all these projects. All have been in the single digits. All OSS is sponsored by Rails Designer. What actually means: me coding away at them. I’ve easily spent <strong>tens of thousands worth of time</strong> on all these projects. 😅</p>
<p>I also <a href="https://railsdesigner.com/rails-ui-consultancy/">helped a dozen companies/teams with improving their UI</a> (and some continue into 2026) and I also <a href="https://railsdesigner.com/saas/month/">built a handful of new SaaS’</a> for people. Great to see them slowly (but surely!) grow their new business. 😊</p>
<p>Next to all that, there were a total of about <a href="https://railsdesigner.com/articles/">60 articles published</a> (and more that didn’t make it! It is not just me anymore). Traffic stayed pretty flat (but still a 16% increase compared to 2024. ’24 saw more “hitș”. ’25 had on average more visitors every month). It is baffling me that the number of visitors could fill up many big stadiums. 🤯</p>
<p><img src="/images/posts/2025-totals.jpg" alt=""><br>
(<em>screenshot taken early December 2025</em>)</p>
<p>The lack of growth I would mostly contribute to <del>AI</del> LLM. It is a trend you see across the industry and I won’t go into it here—but I have thoughts about it.</p>
<p>With that out of the way—without using frecency (frequency + recency) scoring, time decay algorithms or applying exponential decay to older visits—what was the most popular content on Rails Designer in 2025?</p>
<h2>
<a href="#10-components-in-rails-without-gems" aria-hidden="true" class="anchor" id="10-components-in-rails-without-gems"></a>10. <a href="https://railsdesigner.com/vanilla-components/">Components in Rails without gems</a>
</h2>
<p>The need for UI components in Rails apps have been popping up for years. Most popular (based on me observing) is that ViewComponent, followed by Phlex, are the most popular third-party options. But still Rails developers like there to be first-party option. I am unaware of such thing coming (wouldn’t bet on it!), but this article explore how you can get close to a first-party solution.</p>
<h2>
<a href="#9-create-a-macos-inspired-stack-ui-with-stimulus-and-tailwind-css" aria-hidden="true" class="anchor" id="9-create-a-macos-inspired-stack-ui-with-stimulus-and-tailwind-css"></a>9. <a href="https://railsdesigner.com/stimulus-stack-ui/">Create a macOS-inspired stack UI with Stimulus and Tailwind CSS</a>
</h2>
<p>This article shows how to replicate the “macOS stack UI”. It uses a super small Stimulus controller to set an attribute which the CSS works off of (I would use <a href="https://github.com/Rails-Designer/attractivejs/">Attractive.js for that today 😄</a>).</p>
<h2>
<a href="#8-add-a-multi-step-formwizard-to-your-rails-app" aria-hidden="true" class="anchor" id="8-add-a-multi-step-formwizard-to-your-rails-app"></a>8. <a href="https://railsdesigner.com/multistep-forms/">Add a multi-step form/wizard to your Rails app</a>
</h2>
<p>If you need to add a simple Onboarding/Get started set up to your (SaaS) app, this article is for you. It explores a way to have some basic classses that can be easily extended and just as easy copied over to your next app.</p>
<h2>
<a href="#7-build-a-notion-like-editor-with-rails" aria-hidden="true" class="anchor" id="7-build-a-notion-like-editor-with-rails"></a>7. <a href="https://railsdesigner.com/rails-block-editor/">Build a Notion-like editor with Rails</a>
</h2>
<p>This article, first of a two-part series, builds a Notion-like editor using Rails and Stimulus. The first article set ups the foundation in Rails and basic views. The <a href="https://railsdesigner.com/rails-block-editor-part-2/">2nd article</a> dives into extending the features with multiple Stimulus controllers.</p>
<h2>
<a href="#6-turbo-drive-frames-streams-morph-what-to-use" aria-hidden="true" class="anchor" id="6-turbo-drive-frames-streams-morph-what-to-use"></a>6. <a href="https://railsdesigner.com/turbo-drive-frame-stream-morph/">Turbo Drive, Frames, Streams, Morph? What to use?!</a>
</h2>
<p>The morph action was introduced to Turbo. It takes the get request’s body and compares the changes to the existing DOM, then only the changes are injected. This article gives a guideline on what and when to use any of the available options: Drive (enabled by default, Frames, Streams and then Morph). I highlight in this article that I haven’t really used morph and that is still the case. Would love to here from you, in the comments below, of any example how you (successfully) used morph in your (Rails) app.</p>
<h2>
<a href="#5-visual-loading-states-for-turbo-frameswith-css-only" aria-hidden="true" class="anchor" id="5-visual-loading-states-for-turbo-frameswith-css-only"></a>5. <a href="https://railsdesigner.com/visual-loading-turbo-frames/">Visual loading states for Turbo Frames with CSS only</a>
</h2>
<p>I love how powerful CSS <del>has become</del> is. Whenever I can use CSS to add some interactivity, I go for it. In this article I highlight how you can use the provided attributes added by Turbo to add some useful UX to your apps.</p>
<h2>
<a href="#4-create-a-kanban-board-with-rails-and-hotwire-" aria-hidden="true" class="anchor" id="4-create-a-kanban-board-with-rails-and-hotwire-"></a>4. <a href="https://railsdesigner.com/kanban-rails-hotwire/">Create a Kanban board with Rails and Hotwire </a>
</h2>
<p>Who doesn’t love a good kanban board. They are so popular still, that 37signals built a complete product around it, called Fizzy. Here I explain how I would built such a feature, mostly spending time on the correct data model and the UX basics. <a href="https://railsdesigner.com/extending-kanban-rails-hotwire/">The follow-up article</a> extends this article feature set with a few more goodies.</p>
<h2>
<a href="#3-announcing-attractivejs-a-new-javascript-free-javascript-library" aria-hidden="true" class="anchor" id="3-announcing-attractivejs-a-new-javascript-free-javascript-library"></a>3. <a href="https://railsdesigner.com/announcing-attractive-js/">Announcing Attractive.js, a new JavaScript-free JavaScript library</a>
</h2>
<p>Mentioned this project, I am excited about, above already. Attractive.js allows to add interactivity by adding HTML attributes. That’s it. It is minimal by design; just enough for basic Rails apps and more than enough for most static sites (built with <a href="https://github.com/Rails-Designer/perron/">Perron</a>). You all seem to like it too! 😊</p>
<h2>
<a href="#2-building-optimistic-ui-in-rails-and-learn-custom-elements" aria-hidden="true" class="anchor" id="2-building-optimistic-ui-in-rails-and-learn-custom-elements"></a>2. <a href="https://railsdesigner.com/custom-elements/">Building optimistic UI in Rails (and learn custom elements)</a>
</h2>
<p>I see most of the articles from Rails Designer shared on newsletters, curation platforms, socials and Reddit. But the only site that fucks up my stats is the orange site (ie. hackernews). I only learn about it when I check the stats weekly (or a small <a href="https://railsdesigner.com/components/">influx in sales</a> or interest in <a href="https://railsdesigner.com/rails-ui-consultancy/">Rails/UI</a> or <a href="https://railsdesigner.com/saas/month/">SaaS/month</a>).</p>
<p>This article was also shared on it. I do not use or read hackernews, nor Reddit or other socials. But I know HN is infamous for having stong opinions. I wouldn’t know. Live is good.</p>
<p>In this article I explore custom elements (part of the web components standard). It is going from something basic to a quite useful custom element for an optimistic UI. Custom elements is something I will be exploring more in 2026.</p>
<h2>
<a href="#1-10-modern-css-features-you-want-to-use" aria-hidden="true" class="anchor" id="1-10-modern-css-features-you-want-to-use"></a>1. <a href="https://railsdesigner.com/modern-css-overview/">10 Modern CSS Features You Want to Use</a>
</h2>
<p>I mentioned already earlier, but CSS is great! For many things that required JavaScript before, can be done with CSS today. I love it. There are also many features that allows you to craft more precise UI’s, making the designer in me really happy. If you are able, forget Tailwind CSS and explore all modern CSS gives you. While I enjoy using Tailwind CSS, the web is built on (open) standards which is important to keep it accessible, maintainable and future-proof. I might or might not work on something to help with this.</p>
<hr>
<p>And that is it. Last year’s top articles were not too surprising to me. This year’s was, but I am happy to see more love for CSS (or was it the clickbait-y title?). The current queue of articles already goes way into 2026, so there are no immediate plans to stop. But I do keep an eye out on traffic. It is all costing serious time (and money; many tens of thousands) between all the OSS and content work. I might reconsider if traffic is declining against <del>AI</del> LLMs.</p>
<p>I ended last year’s article stating (hoping?) that 2025 would be amazing. And looking back at it, after writing this article, I can confidently say it was a good year. With the foundation of above OSS projects and other work done, I am looking forward to 2026.</p>
]]></content>
  </entry>
  <entry>
    <title>Add snow to your app with Stimulus</title>
    <link href="https://railsdesigner.com/let-it-snow-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-12-18T07:15:00Z</published>
    <updated>2025-12-18T07:15:00Z</updated>
    <id>https://railsdesigner.com/let-it-snow-stimulus/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/let-it-snow-stimulus/?ref=rss"><![CDATA[<div data-controller="let-it-snow" data-let-it-snow-intensity-value="light" data-action="mousemove@document-&gt;let-it-snow#trackMouse click@document-&gt;let-it-snow#sweepAwaySnow" class="isolate">
</div>
<p>With the end of 2025 near, let’s build something fun: a snow effect for your (Rails) app or site (built with <a href="https://perron.railsdesigner.com/">Perron</a>?) using one Stimulus controller. Snow will fall from the top of the viewport, pile up at the bottom and you can sweep it away by dragging your mouse. Give it a try on this page! 😊☃️</p>
<h2>
<a href="#creating-the-basic-controller" aria-hidden="true" class="anchor" id="creating-the-basic-controller"></a>Creating the basic controller</h2>
<p>Start with the controller structure and lifecycle methods. Create a new Stimulus controller:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/controllers/let_it_snow_controller.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">startSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">disconnect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">stopSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">meltSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">startSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">stopSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">cancelAnimationFrame</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">animate</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> animation loop goes here
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">meltSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> cleanup goes here
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #animationFrame = null
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>connect</code> method starts the snow animation when the controller initializes. The <code>disconnect</code> method cleans everything up when the controller is removed. The animation loop uses <code>requestAnimationFrame</code> for smooth 60fps animation. This browser API synchronizes animations with the screen refresh rate, so animationns are smooth (all while automatically pausing when the tab is not visible to save your CPU from running hot and melting all snow on its own).</p>
<p>Do not forget to add the controller to any element:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">let-it-snow</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<h2>
<a href="#let-it-snow-%EF%B8%8F" aria-hidden="true" class="anchor" id="let-it-snow-️"></a>Let it snow! ☃️</h2>
<p>Now create snowflakes and make them fall. Add the snowflake creation logic:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">&lt; </span><span style="font-weight:bold;color:#d97706;">0.02</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowflake</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowflake</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">("</span><span style="color:#0369a1;">div</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">❄️</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakeStyles</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakePhysics</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">body</span><span style="color:#475569;">.</span><span style="color:#0284c7;">appendChild</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakeStyles</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  Object</span><span style="color:#475569;">.</span><span style="color:#1e293b;">assign</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">, {
</span><span style="color:#0c4a6e;">    position</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">fixed</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    left</span><span style="color:#475569;">: `${</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`,
</span><span style="color:#0c4a6e;">    top</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">-50px</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    fontSize</span><span style="color:#475569;">: `${</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">20 </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">15</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`,
</span><span style="color:#0c4a6e;">    pointerEvents</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">none</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    zIndex</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">9999</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    userSelect</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">none</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    isolation</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">isolate</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakePhysics</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityY </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">1 </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">0.5</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">0.5 </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">0.25</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">0</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotationSpeed </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">2 </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> Private
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span></code></pre>
<p>Each snowflake is a div with the ❄️ emoji. The styles position it at a random horizontal location above the viewport. The physics data attributes control how fast it falls and drifts sideways. The <code>isolation: isolate</code> property ensures snowflakes don’t interfere with text selection or clicking on your page content (try selecting—with the broom some text on this page).</p>
<h2>
<a href="#animate-all-the-snow-%EF%B8%8F" aria-hidden="true" class="anchor" id="animate-all-the-snow-️"></a>Animate all the snow! ☃️</h2>
<p>Make the snowflakes move down the screen with some gentle horizontal drift:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">updateFallingSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">updateFallingSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">bottomThreshold </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">moveSnowflake</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">getSnowflakeTop</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&gt;= </span><span style="color:#1e293b;">bottomThreshold</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">moveSnowflake</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">velocityY </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityY</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">rotationSpeed </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotationSpeed</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">let </span><span style="color:#1e293b;">newLeft </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">velocityX
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">newLeft </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">constrainHorizontally</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newLeft</span><span style="color:#475569;">, </span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">velocityY</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">newLeft</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">transform </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`</span><span style="color:#0369a1;">rotate(</span><span style="color:#475569;">${</span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">rotationSpeed</span><span style="color:#475569;">}</span><span style="color:#0369a1;">deg)</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">rotationSpeed</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">constrainHorizontally</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">, </span><span style="color:#1e293b;">left</span><span style="color:#475569;">, </span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">&lt; </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">abs</span><span style="color:#475569;">(</span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">-</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">abs</span><span style="color:#475569;">(</span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">)).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">left
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">getSnowflakeTop</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> Private
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span></code></pre>
<p>The snowflakes fall at their velocity and rotate as they go. When they hit the edges of the viewport they bounce back. When they reach the bottom they’re marked as settled. Cool, right? 😅</p>
<h2>
<a href="#pile-up-all-the-snow-%EF%B8%8F" aria-hidden="true" class="anchor" id="pile-up-all-the-snow-️"></a>Pile up all the snow! ☃️</h2>
<p>“Settled” snowflakes need to stop falling and “pile” at the bottom (laws of nature demands that from us):</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">updateFallingSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">settleSnowAtBottom</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">settleSnowAtBottom</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">settledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">finalTop </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">30
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">finalTop</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">bottomOffset </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">increaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">increaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">count</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">+= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">3600
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> Private
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span></code></pre>
<p>Settled snowflakes get positioned at the top of the existing pile. The accumulated height grows with each snowflake that settles. This creates a visible pile of snow at the bottom of the viewport.</p>
<h2>
<a href="#sweep-away-all-the-snow-%EF%B8%8F" aria-hidden="true" class="anchor" id="sweep-away-all-the-snow-️"></a>Sweep away all the snow! ☃️</h2>
<p>Add the broom cursor and sweeping functionality:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">static values </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  intensity</span><span style="color:#475569;">: { </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">String</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">default</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">light</span><span style="color:#475569;">" },
</span><span style="color:#0c4a6e;">  pixelsPerMinute</span><span style="color:#475569;">: { </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Number</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">default</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">100 </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">  broomActive</span><span style="color:#475569;">: { </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Boolean</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">default</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">false </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">trackMouse</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">buttons </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">buttons </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">sweepAwaySnow</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">broomActiveValueChanged</span><span style="color:#475569;">(</span><span style="color:#1e293b;">isBrooming</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">body</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">cursor </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">isBrooming
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">url('data:image/svg+xml;utf8,&lt;svg xmlns=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">http://www.w3.org/2000/svg</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> width=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> height=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> viewBox=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">0 0 32 32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">&gt;&lt;text y=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">28</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> font-size=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">28</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">&gt;🧹&lt;/text&gt;&lt;/svg&gt;') 0 32, auto</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">default</span><span style="color:#475569;">"
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">sweepRadius </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">80
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">sweptSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findSnowInRadius</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">sweepRadius</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">includes</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">decreaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findSnowInRadius</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">radius</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">distance </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">sqrt</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">pow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">left</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">pow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseY </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">top</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">distance </span><span style="font-weight:bold;color:#0369a1;">&lt;= </span><span style="color:#1e293b;">radius
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">decreaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">count</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">pixelsToRemove </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">3600
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">max</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">pixelsToRemove</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span></code></pre>
<p>When you hold down the mouse button the cursor changes to a broom. Drag it around to sweep away snow. The accumulated height decreases as you remove snow. 🧹</p>
<p>Add the mouse tracking to your view:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">let-it-snow</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">mousemove@document-&gt;let-it-snow#trackMouse click@document-&gt;let-it-snow#sweepAwaySnow</span><span style="color:#475569;">"
</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<h2>
<a href="#clean-up-all-the-snow-%EF%B8%8F" aria-hidden="true" class="anchor" id="clean-up-all-the-snow-️"></a>Clean up all the snow! ☃️</h2>
<p>Complete the cleanup method to remove all snowflakes:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">meltSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#475569;">}
</span></code></pre>
<p>This removes all snowflake elements from the DOM and clears the arrays when the controller disconnects.</p>
<p>Here is the full implementation:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/controllers/let_it_snow_controller.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">values </span><span style="color:#0c4a6e;">= {
</span><span style="color:#0c4a6e;">    pixelsPerMinute: { type: Number, default: 100 </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">broomActive</span><span style="color:#0c4a6e;">: </span><span style="color:#475569;">{ </span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Boolean</span><span style="color:#475569;">, </span><span style="color:#1e293b;">default</span><span style="color:#0c4a6e;">: </span><span style="font-weight:bold;color:#075985;">false </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">connect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">startSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">disconnect</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">stopSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">meltSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">trackMouse</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">buttons </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">buttons </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">sweepAwaySnow</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">broomActiveValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">clientX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">clientY</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> Private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">intensityMap </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    flurry</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">60</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    light</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">200</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    steady</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">500</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    heavy</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1000</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    blizzard</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">2000
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">intensityValueChanged</span><span style="color:#475569;">(</span><span style="color:#1e293b;">newIntensity</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">intensityMap</span><span style="color:#475569;">[</span><span style="color:#1e293b;">newIntensity</span><span style="color:#475569;">]) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">intensityMap</span><span style="color:#475569;">[</span><span style="color:#1e293b;">newIntensity</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">broomActiveValueChanged</span><span style="color:#475569;">(</span><span style="color:#1e293b;">isBrooming</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">body</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">cursor </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">isBrooming
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">url('data:image/svg+xml;utf8,&lt;svg xmlns=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">http://www.w3.org/2000/svg</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> width=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> height=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> viewBox=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">0 0 32 32</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">&gt;&lt;text y=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">28</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> font-size=</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">28</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">&gt;🧹&lt;/text&gt;&lt;/svg&gt;') 0 32, auto</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">default</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">startSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">stopSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">cancelAnimationFrame</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">null
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">updateFallingSnow</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">settleSnowAtBottom</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animationFrame </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">requestAnimationFrame</span><span style="color:#475569;">(() </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">animate</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">createSnowBasedOnIntensity</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">probability </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">min</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">0.5</span><span style="color:#475569;">, (</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">1000</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">0.3</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">&lt; </span><span style="color:#1e293b;">probability</span><span style="color:#475569;">) </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">createSnowflake</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">createSnowflake</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">("</span><span style="color:#0369a1;">div</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">❄️</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakeStyles</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">applySnowflakePhysics</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">body</span><span style="color:#475569;">.</span><span style="color:#0284c7;">appendChild</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">applySnowflakeStyles</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    Object</span><span style="color:#475569;">.</span><span style="color:#1e293b;">assign</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">, {
</span><span style="color:#0c4a6e;">      position</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">fixed</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      left</span><span style="color:#475569;">: `${</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`,
</span><span style="color:#0c4a6e;">      top</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">-50px</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      fontSize</span><span style="color:#475569;">: `${</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">20 </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">15</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`,
</span><span style="color:#0c4a6e;">      pointerEvents</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">none</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      zIndex</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">9999</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      userSelect</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">none</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      isolation</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">isolate</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">applySnowflakePhysics</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityY </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">1 </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="font-weight:bold;color:#d97706;">0.5</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">0.5 </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">0.25</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">0</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotationSpeed </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">random</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">2 </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">updateFallingSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">bottomThreshold </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">moveSnowflake</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">getSnowflakeTop</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&gt;= </span><span style="color:#1e293b;">bottomThreshold</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">        </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">moveSnowflake</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">velocityY </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityY</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">rotationSpeed </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotationSpeed</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">let </span><span style="color:#1e293b;">newLeft </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">velocityX
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">newLeft </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">constrainHorizontally</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newLeft</span><span style="color:#475569;">, </span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">velocityY</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">newLeft</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">transform </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`</span><span style="color:#0369a1;">rotate(</span><span style="color:#475569;">${</span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">rotationSpeed</span><span style="color:#475569;">}</span><span style="color:#0369a1;">deg)</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">rotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">rotation </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">rotationSpeed</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">constrainHorizontally</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">, </span><span style="color:#1e293b;">left</span><span style="color:#475569;">, </span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">&lt; </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">abs</span><span style="color:#475569;">(</span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">velocityX </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">-</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">abs</span><span style="color:#475569;">(</span><span style="color:#1e293b;">velocityX</span><span style="color:#475569;">)).</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerWidth
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">left
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">getSnowflakeTop</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">settleSnowAtBottom</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">settledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">finalTop </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">innerHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="font-weight:bold;color:#d97706;">30
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">finalTop</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">bottomOffset </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toString</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">settled </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#475569;">"</span><span style="color:#0369a1;">true</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">increaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">settledSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">increaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">count</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">+= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">3600
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">sweepAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">sweepRadius </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">80
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">sweptSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">findSnowInRadius</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">sweepRadius</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">includes</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">decreaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sweptSnow</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">findSnowInRadius</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX</span><span style="color:#475569;">, </span><span style="color:#1e293b;">mouseY</span><span style="color:#475569;">, </span><span style="color:#1e293b;">radius</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">left </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">top </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseFloat</span><span style="color:#475569;">(</span><span style="color:#1e293b;">snowflake</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">distance </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">sqrt</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">pow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseX </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">left</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">pow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">mouseY </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">top</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">))
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">distance </span><span style="font-weight:bold;color:#0369a1;">&lt;= </span><span style="color:#1e293b;">radius
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">decreaseAccumulatedSnow</span><span style="color:#475569;">(</span><span style="color:#1e293b;">count</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">pixelsToRemove </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">pixelsPerMinuteValue</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">3600
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Math</span><span style="color:#475569;">.</span><span style="color:#1e293b;">max</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">accumulatedHeight </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">pixelsToRemove</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">meltSnow</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">s </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">s</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fallingSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">piledSnow </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>I hope this brought a little smile to your face all while learning a few new little JavaScript features. 😊 This will be the last article from Rails Designer for 2025, except for something little next week.</p>
<p>From me to you: thanks for your support in 2025 by either reading my articles, getting a copy of <a href="https://railsdesigner.com/components/">Rails Designer’s UI Components Library</a> or <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a> or using any of the <a href="https://github.com/Rails-Designer">Rails Designer-sponsored OSS projects</a>. Would love to see you again here in 2026. ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Requestkit: test and send webhooks and API requests in development</title>
    <link href="https://railsdesigner.com/new-requestkit/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-12-11T07:30:00Z</published>
    <updated>2025-12-11T09:15:00+00:00</updated>
    <id>https://railsdesigner.com/new-requestkit/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/new-requestkit/?ref=rss"><![CDATA[<p>Requestkit is inspired by Stripe’s CLI to send payloads to your app, but it instead can send (and receive!) <em>any</em> payload. It runs entirely on your machine. It’s a local HTTP server that captures incoming HTTP requests (webhooks) and send outbound requests (API).</p>
<p>Requestkit is fast, requires zero configuration and configuration can be easily shared within projects. Install it as a Ruby gem, start the server and point your webhooks to <code>localhost:4000</code>.</p>
<p><img src="/images/posts/requestkit-screenshot.jpg" alt=""></p>
<p><a href="https://github.com/Rails-Designer/requestkit">⭐ Star the project on GitHub ⭐</a></p>
<h2>
<a href="#tell-me-more" aria-hidden="true" class="anchor" id="tell-me-more"></a>Tell me more!</h2>
<p>Requestkit is a local HTTP request toolkit for development. Sounds fancy, but what can you do with it?</p>
<ul>
<li>
<strong>See webhook payloads</strong>: view complete headers and body data for every request in a clean interface;</li>
<li>
<strong>Send HTTP requests</strong>: test outbound HTTP calls to your local API endpoint or external services and inspect their responses;</li>
<li>
<strong>Organize by namespace</strong>: automatically groups requests by URL path (e.g., <code>/stripe</code>, <code>/github</code>) for easy filtering;</li>
<li>
<strong>Persist across restarts</strong>: optional file storage keeps your request history between sessions.</li>
</ul>
<h2>
<a href="#how-does-it-work" aria-hidden="true" class="anchor" id="how-does-it-work"></a>How does it work?</h2>
<p>Requestkit runs as a local web server on your machine (default: <code>http://localhost:4000</code>). When you send any HTTP request to it, Requestkit captures the complete request including headers and body, then stores it for inspection. Open the web app in your browser to see all captured requests organized by namespace, with full details available at a glance.</p>
<p>The tool uses SQLite for storage, giving you the choice between in-memory mode (fast, temporary) or file-based persistence. You can configure defaults via YAML files at the user or project level and override them with CLI flags. Namespaces are automatically extracted from URL paths, so <code>/stripe/webhook</code> goes into the “stripe” namespace while <code>/github/push</code> goes into “github”.</p>
<p><strong>In short:</strong></p>
<ul>
<li>Install via <code>gem install requestkit</code>;</li>
<li>Start with <code>requestkit</code> (or customize port/storage with flags);</li>
<li>Send requests to <code>http://localhost:4000/your-namespace/path</code>;</li>
<li>For outbound requests, create JSON files defining the HTTP call (in <code>.requestkit/requests/:namespace/:name.json</code>);</li>
<li>View everything in your browser at <code>http://localhost:4000</code>.</li>
</ul>
<p><img src="/images/requestkit-preview.gif" alt=""></p>
<h2>
<a href="#behind-the-scenes" aria-hidden="true" class="anchor" id="behind-the-scenes"></a>Behind the scenes</h2>
<p>Requestkit is built with Ruby and uses some interesting tools. It uses <strong>Ruby Async</strong> with Server-Sent Events (SSE) to push new requests to the browser instantly, so the codebase stays lightweight and dependencies minimal. The server itself runs on <a href="https://github.com/socketry/async-http">async-http</a>, which handles concurrent connections efficiently without the overhead of a full web framework.</p>
<p>Storage is handled by <strong>SQLite3</strong> with a simple schema that tracks request direction (inbound &amp; outbound), namespaces, timestamps and parent relationships for request/response pairs (to be used later). The web UI is a single ERB template with vanilla CSS (using <a href="https://github.com/flavorjones/tailwindcss-ruby">tailwindcss-ruby</a>). No JavaScript frameworks, but using <a href="https://attractivejs.railsdesigner.com/">Attractive.js</a> for the odd interactivity. For outbound requests, Requestkit uses <a href="https://github.com/socketry/async-http">async-http</a> with custom SSL contexts to handle HTTPS endpoints.</p>
<p>As with most OSS I share (gift), I publish a very early first version in the hopes to inspire you to build along with me. 😊 Explore the code and contribute on <a href="https://github.com/Rails-Designer/requestkit">GitHub</a></p>
]]></content>
  </entry>
  <entry>
    <title>More readable integer comparisons in Ruby</title>
    <link href="https://railsdesigner.com/comparable-integers/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-12-10T07:30:00Z</published>
    <updated>2025-12-10T07:30:00Z</updated>
    <id>https://railsdesigner.com/comparable-integers/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/comparable-integers/?ref=rss"><![CDATA[<p>Let’s be honest: comparison operators like <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code> and <code>&gt;=</code> work perfectly fine, but they’re not so readable (even more so when you’re scanning through code quickly or working with complex conditionals).</p>
<p>What if instead of writing <code>seats &lt; 10</code> you could write <code>seats.below? 10</code>? Or <code>usage.at_least? limit</code> instead of <code>usage &gt;= limit</code>? Much clearer, right?</p>
<p>This is where <a href="https://railsdesigner.com/saas/ruby-refinements/">refinements</a> come in handy (in short: add methods to existing classes without the risks of monkey patching).</p>
<p>Here’s a simple refinement module that I copy between my apps to make integer comparisons more expressive:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> lib/comparable_integer.rb (reusable modules and classes are stored in `lib/`)
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">ComparableInteger
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">refine Integer </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">below?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">other</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">self </span><span style="font-weight:bold;color:#0369a1;">&lt;</span><span style="color:#0c4a6e;"> other
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">above?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">other</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">self </span><span style="font-weight:bold;color:#0369a1;">&gt;</span><span style="color:#0c4a6e;"> other
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">at_most?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">other</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">self </span><span style="font-weight:bold;color:#0369a1;">&lt;=</span><span style="color:#0c4a6e;"> other
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">at_least?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">other</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">self </span><span style="font-weight:bold;color:#0369a1;">&gt;=</span><span style="color:#0c4a6e;"> other
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Now imagine you’re building subscription logic that checks seat limits:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">SubscriptionsController </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationController
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">using </span><span style="color:#1e293b;">ComparableInteger
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">upgrade
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> current_seats</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">below? requested_seats
</span><span style="color:#0c4a6e;">      charge_additional_seats
</span><span style="color:#0c4a6e;">      update_subscription
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      redirect_to subscription_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">notice</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Upgraded successfully</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">else
</span><span style="color:#0c4a6e;">      redirect_to subscription_path</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">alert</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Cannot downgrade seats</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">current_seats</span><span style="color:#0c4a6e;"> = @</span><span style="color:#1e293b;">subscription</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">seats
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">requested_seats</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">params</span><span style="color:#0c4a6e;">[</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">seats</span><span style="color:#475569;">].</span><span style="color:#0284c7;">to_i
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>The code reads almost like plain English. <code>current_seats.below? requested_seats</code> is immediately clear, while <code>current_seats &lt; requested_seats</code> requires a mental translation.</p>
<p>Or another example when dealing with pricing tiers or feature limits:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">FeatureAccess
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">using </span><span style="color:#1e293b;">ComparableInteger
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">initialize</span><span style="color:#475569;">(</span><span style="color:#1e293b;">subscription</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">subscription </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> subscription
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">can_access_advanced_analytics?
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">subscription</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">amount_in_cents</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">at_least? </span><span style="font-weight:bold;color:#d97706;">4999
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">needs_team_plan?
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">subscription</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">seats</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">above? </span><span style="font-weight:bold;color:#d97706;">1
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">qualifies_for_discount?
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">subscription</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">months_active</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">at_least? </span><span style="font-weight:bold;color:#d97706;">12
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Much more readable than a sea of <code>&gt;=</code> and <code>&gt;</code> symbols, don’t you think? Got more ideas for making Ruby code more expressive? <a href="#comments">Let me know in the comments below</a>.</p>
]]></content>
  </entry>
  <entry>
    <title>Building optimistic UI in Rails (and learn custom elements)</title>
    <link href="https://railsdesigner.com/custom-elements/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-12-04T07:30:00Z</published>
    <updated>2025-12-04T07:30:00Z</updated>
    <id>https://railsdesigner.com/custom-elements/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/custom-elements/?ref=rss"><![CDATA[<p>Custom elements are one of those web platform features that sound complicated but turn out to be surprisingly simple. If you have used Hotwire in Rails, you have already used them. Both <code>&lt;turbo-frame&gt;</code> and <code>&lt;turbo-stream&gt;</code> <a href="https://railsdesigner.com/turbo-streams-behind-the-scenes/">are custom elements</a>. They are just HTML tags with JavaScript behavior attached.</p>
<p>This article walks through what custom elements are, how they compare to Stimulus controllers and how to build them yourself! Starting with a simple counter and ending with an <strong>optimistic form that updates instantly without waiting for the server</strong>. 🤯</p>
<p><a href="https://github.com/rails-designer-repos/custom-elements/">The code is available on GitHub</a>.</p>
<h2>
<a href="#what-are-custom-elements" aria-hidden="true" class="anchor" id="what-are-custom-elements"></a>What are custom elements?</h2>
<p>Custom elements let you define your own HTML tags with custom behavior. They fall under the Web Components umbrella, alongside Shadow DOM (encapsulated styling and markup) and templates (reusable HTML fragments), though each can be used independently. To use custom elements, just define a class, register it with the browser and use your new tag anywhere (Shadow DOM or templates not required).</p>
<p>Here is the simplest possible custom element:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">HelloWorld </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Hello from a custom element 👋</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">hello-world</span><span style="color:#475569;">", </span><span style="color:#1e293b;">HelloWorld</span><span style="color:#475569;">)
</span></code></pre>
<p>Now you can use <code>&lt;hello-world&gt;&lt;/hello-world&gt;</code> in your HTML and it will display the message. The <code>connectedCallback</code> runs when the element is added to the page. This is similar to Stimulus’s <code>connect()</code> method. There is also, just like with Stimulus, a <code>disconnectedCallback</code>. This can be used similar to Stimulus’: removing event listeners and so on.</p>
<p>Custom element names must contain a hyphen. This prevents conflicts with future HTML elements. So <code>&lt;hello-world&gt;</code> works, but <code>&lt;helloworld&gt;</code> does not.</p>
<p>I really enjoy using custom elements in static sites for components that need tight-integrated/attached logic. Like these components from the <a href="https://perron.railsdesigner.com/">Perron Library</a>: <a href="https://perron.railsdesigner.com/library/countdown/">countdown</a>, <a href="https://perron.railsdesigner.com/library/faq-component-tailwind/">FAQ (accordion)</a> or a <a href="https://perron.railsdesigner.com/library/table-of-content/">Table of Content</a>.</p>
<h2>
<a href="#attributes-and-properties" aria-hidden="true" class="anchor" id="attributes-and-properties"></a>Attributes and properties</h2>
<p>Custom elements can read attributes just like regular HTML elements:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">GreetUser </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">name </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">name</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">stranger</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`</span><span style="color:#0369a1;">Hello, </span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">!</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">greet-user</span><span style="color:#475569;">", </span><span style="color:#1e293b;">GreetUser</span><span style="color:#475569;">)
</span></code></pre>
<p>Use it like this:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">greet-user </span><span style="color:#0369a1;">name</span><span style="color:#475569;">="</span><span style="color:#0369a1;">Cam</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">greet-user</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>To react to attribute changes, use <code>attributeChangedCallback</code>:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">GreetUser </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">observedAttributes </span><span style="color:#0c4a6e;">= ["name"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">attributeChangedCallback</span><span style="color:#475569;">(</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">oldValue</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">render</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">name </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">name</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">stranger</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`</span><span style="color:#0369a1;">Hello, </span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">!</span><span style="color:#475569;">`
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>observedAttributes</code> array tells the browser which attributes to watch. Without it, <code>attributeChangedCallback</code> never fires.</p>
<h3>
<a href="#the-is-attribute" aria-hidden="true" class="anchor" id="the-is-attribute"></a>The <code>is</code> attribute</h3>
<p>You can extend built-in HTML elements using the <code>is</code> attribute. For example, extending a button:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">FancyButton </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLButtonElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">classList</span><span style="color:#475569;">.</span><span style="color:#0284c7;">add</span><span style="color:#475569;">("</span><span style="color:#0369a1;">fancy</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">fancy-button</span><span style="color:#475569;">", </span><span style="color:#1e293b;">FancyButton</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">extends</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">button</span><span style="color:#475569;">" })
</span></code></pre>
<p>Then use it like:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">is</span><span style="color:#475569;">="</span><span style="color:#0369a1;">fancy-button</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Click me</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>This keeps all the built-in button behavior while adding your custom features (simply adding the <code>fancy</code> class in above example). However, <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/is">Safari does not support this feature</a>. So I stick to autonomous custom elements (the hyphenated tags) for better compatibility.</p>
<h2>
<a href="#custom-elements-vs-stimulus" aria-hidden="true" class="anchor" id="custom-elements-vs-stimulus"></a>Custom elements vs Stimulus</h2>
<p>If you have used Stimulus, custom elements will feel familiar. Here is how they compare:</p>
<table>
<thead>
<tr>
<th align="left">Feature</th>
<th align="left">Stimulus</th>
<th align="left">Custom Element</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">Lifecycle</td>
<td align="left">
<code>connect()</code> / <code>disconnect()</code>
</td>
<td align="left">
<code>connectedCallback()</code> / <code>disconnectedCallback()</code>
</td>
</tr>
<tr>
<td align="left">Finding elements</td>
<td align="left"><code>targets</code></td>
<td align="left">
<code>querySelector()</code> / direct children</td>
</tr>
<tr>
<td align="left">State</td>
<td align="left"><code>values</code></td>
<td align="left">attributes + properties</td>
</tr>
<tr>
<td align="left">Events</td>
<td align="left">
<code>action</code> attributes</td>
<td align="left"><code>addEventListener()</code></td>
</tr>
<tr>
<td align="left">Framework</td>
<td align="left">Requires Stimulus</td>
<td align="left">Browser-native</td>
</tr>
</tbody>
</table>
<p>Stimulus is great for connecting behavior to existing HTML. Custom elements are better when you want a reusable component that works anywhere. They are also simpler when you do not need Stimulus’s conventions.</p>
<p>The main difference is how you find elements. Stimulus uses targets:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">counter</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">span </span><span style="color:#0369a1;">data-counter-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">count</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">0</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">span</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">click-&gt;counter#increment</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">+</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Custom elements use standard DOM/query methods (see example below):</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">click-counter</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">span </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">count</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">0</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">span</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">+</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">click-counter</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Custom elements feel more like writing vanilla JavaScript. Stimulus is more convention-based (which is often confusing to many).</p>
<p>In the end it is all a regular JavaScript class. I’ve explored these extensively in the book <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a>. 💡</p>
<h2>
<a href="#building-a-simple-counter" aria-hidden="true" class="anchor" id="building-a-simple-counter"></a>Building a simple counter</h2>
<p>Time to build something. Start with a counter that increments when clicked:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/components/click_counter.js
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">ClickCounter </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">count </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">click</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">increment</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">increment</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">count</span><span style="font-weight:bold;color:#0369a1;">++
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">span</span><span style="color:#475569;">").</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">count
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">click-counter</span><span style="color:#475569;">", </span><span style="color:#1e293b;">ClickCounter</span><span style="color:#475569;">)
</span></code></pre>
<p>Import it in your application JavaScript:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/application.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/turbo-rails</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components/click_counter</span><span style="color:#475569;">"
</span></code></pre>
<p>Configure importmap to find the new directory:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> config/importmap.rb
</span><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/controllers</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">controllers</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">pin_all_from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">app/javascript/components</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">under</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components</span><span style="color:#475569;">"
</span></code></pre>
<p>Now use it in your views:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">click-counter</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Clicked </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">span</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">0</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">span</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;"> times</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">click-counter</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Click the button and watch the counter increment. Simple! 😊</p>
<h2>
<a href="#building-an-optimistic-form" aria-hidden="true" class="anchor" id="building-an-optimistic-form"></a>Building an optimistic form</h2>
<p>Now for something a bit more useful. Build a form that updates instantly without waiting for the server. If the save fails, show an error. If it succeeds, keep the optimistic UI.</p>
<p>It will look like this:</p>
<p><img src="/images/posts/optimistic-ui.gif" alt=""></p>
<p>See how the message gets appended immediately and then (notice the blue Turbo progress bar) gets replaced with the server rendered version.</p>
<p>The HTML looks like this:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">optimistic-form</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">form </span><span style="color:#0369a1;">action</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> messages_path </span><span style="color:#475569;">%&gt;" </span><span style="color:#0369a1;">method</span><span style="color:#475569;">="</span><span style="color:#0369a1;">post</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> hidden_field_tag </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">authenticity_token</span><span style="color:#475569;">,</span><span style="color:#0c4a6e;"> form_authenticity_token </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> text_area_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">message[content]</span><span style="color:#475569;">", "", </span><span style="font-weight:bold;color:#075985;">placeholder</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Write a message…</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">required</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> submit_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Send</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">form</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">template </span><span style="color:#0369a1;">response</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= </span><span style="color:#0284c7;">render </span><span style="color:#0c4a6e;">Message</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">content</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"", </span><span style="font-weight:bold;color:#075985;">created_at</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">Time</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">current</span><span style="color:#475569;">) %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">optimistic-form</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The <code>&lt;template response&gt;</code> tag (indeed also part of the Web Components standard) holds the display HTML for new messages. When the form submits, the custom element renders this template with the form values and appends it to the list. The form still submits normally to Rails.</p>
<p>Start with the basic structure:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/components/optimistic_form.js
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">OptimisticForm </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">form</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">template </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">template[response]</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">#messages</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">submit</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">submit</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">submit</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">checkValidity</span><span style="color:#475569;">()) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">formData </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">FormData</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">optimisticElement </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">formData</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">append</span><span style="color:#475569;">(</span><span style="color:#1e293b;">optimisticElement</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">optimistic-form</span><span style="color:#475569;">", </span><span style="color:#1e293b;">OptimisticForm</span><span style="color:#475569;">)
</span></code></pre>
<p>The <code>submit</code> method checks <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/checkValidity">form validity</a> first using the browser’s built-in validation. If the form is invalid, let the browser show its validation messages. Otherwise render the optimistic UI and let the form submit normally.</p>
<h3>
<a href="#getting-optimistic" aria-hidden="true" class="anchor" id="getting-optimistic"></a>Getting optimistic</h3>
<p>Extract the form data and populate the template:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">formData</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">template</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">.</span><span style="color:#0284c7;">cloneNode</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">firstElementChild
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">optimistic-message</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">[</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">value</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">of </span><span style="color:#1e293b;">formData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">entries</span><span style="color:#475569;">()) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">(`</span><span style="color:#0369a1;">[data-field="</span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">"]</span><span style="color:#475569;">`)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">) </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">value
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">element
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>cloneNode(true)</code> creates a copy of the template content. Loop through the form data and update any element with a matching <code>data-field</code> attribute. This is why the partial has a <code>data-field="message[content]"</code> on the message display.</p>
<p>The optimistic element appears in the list immediately, then the form submits to Rails.</p>
<p>The Turbo Stream does not append the message, but replaces the “optimistic message” with the real one from the database:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/messages/create.turbo_stream.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">replace </span><span style="color:#475569;">"</span><span style="color:#0369a1;">optimistic-message</span><span style="color:#475569;">", @</span><span style="color:#1e293b;">message </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Since both the optimistic template and the message partial render the same HTML, the replacement is seamless. The user sees the message appear instantly, then it gets replaced with the real version (with the correct ID, timestamp, etc.) a moment later.</p>
<p>Here is the full implementation:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/components/optimistic_form.js
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">OptimisticForm </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">form</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">template </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">template[response]</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">#messages</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">submit</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">submit</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">turbo:submit-end</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">reset</span><span style="color:#475569;">())
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">submit</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#1e293b;">checkValidity</span><span style="color:#475569;">()) </span><span style="font-weight:bold;color:#dc2626;">return
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">formData </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">FormData</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">optimisticElement </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">formData</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">append</span><span style="color:#475569;">(</span><span style="color:#1e293b;">optimisticElement</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">formData</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">template</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">.</span><span style="color:#0284c7;">cloneNode</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">firstElementChild
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">id </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">optimistic-message</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">for </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#475569;">[</span><span style="color:#1e293b;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">value</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">of </span><span style="color:#1e293b;">formData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">entries</span><span style="color:#475569;">()) {
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">(`</span><span style="color:#0369a1;">[data-field="</span><span style="color:#475569;">${</span><span style="color:#1e293b;">name</span><span style="color:#475569;">}</span><span style="color:#0369a1;">"]</span><span style="color:#475569;">`)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">) </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">value
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">element
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">reset</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">form</span><span style="color:#475569;">.</span><span style="color:#0284c7;">reset</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">optimistic-form</span><span style="color:#475569;">", </span><span style="color:#1e293b;">OptimisticForm</span><span style="color:#475569;">)
</span></code></pre>
<p>Do not forget to import it:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/application.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">components/optimistic_form</span><span style="color:#475569;">"
</span></code></pre>
<p>Now when you submit the form, the message appears instantly in the list. The form submits to Rails, which responds with a Turbo Stream (I added <code>sleep</code> to mimic a slow response) that replaces the optimistic message with the real one. If the save fails, Rails can show an error message normally.</p>
<p>Cool, right? I’ve used this technique before <a href="https://railsdesigner.com/saas/month/">with a client</a> successfully. Many months later and it holds up nicely.</p>
<hr>
<p>This pattern can work great for any form where you want instant feedback. Like chat messages, comments or todos. The new item appears immediately. No loading spinners, no waiting.</p>
<p>The key is that the partial lives right within the <code>template</code> element. You are not duplicating it in JavaScript. Change the partial and the optimistic UI updates automatically.</p>
<p>Custom elements make this pattern reusable. Drop <code>&lt;optimistic-form&gt;</code> anywhere in your app. It works with any form and any partial (with client’s project mentioned above I stubbed the partial with more advanved “stand-in” model instance).</p>
<p>Yet another tool in your Rails toolkit. Did this inspire you to use custom elements more too? Let me know below! ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Black Friday/Cyber Monday deal 2025</title>
    <link href="https://railsdesigner.com/bfcm-2025/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-11-25T08:30:00Z</published>
    <updated>2025-11-25T08:30:00Z</updated>
    <id>https://railsdesigner.com/bfcm-2025/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/bfcm-2025/?ref=rss"><![CDATA[<p>This year, just like last year, I offer a nice 30% off on both <a href="/components/">Rails Designer’s UI Components</a> and <a href="https://javascriptforrails.com/">JavaScript For Rails Developers</a>. 🤑</p>
<p>Enter <strong>BFCM2025</strong> on check out to <strong>get a nice 30% off on both products</strong>! The coupon is valid until the 2nd of December.</p>
<p>Enjoy it! 🎉</p>
<hr>
<p>While you are here, let me highlight some of the cool OSS projects I have been working on:</p>
<h2>
<a href="#perron" aria-hidden="true" class="anchor" id="perron"></a><a href="https://perron.railsdesigner.com/">Perron</a>
</h2>
<p>Static Site Generator for Ruby on Rails. It is pretty cool and <a href="https://perron.railsdesigner.com/docs/sites/">already some good-looking websites are built</a> with it. I recently added a feature to <strong>automate content generation using data files</strong> (like CSV and JSON) which is great for things like programmatic SEO (the secret of many SaaS companies out there).</p>
<h2>
<a href="#attractivejs" aria-hidden="true" class="anchor" id="attractivejs"></a><a href="https://attractivejs.railsdesigner.com/">Attractive.js</a>
</h2>
<p>A JavaScript-free JavaScript library. It works great with static sites, but I also use it for common functionalities that I previous created a Stimulus controller for. Less overhead and thus shipping faster! Woohoo!</p>
<h2>
<a href="#requestkit" aria-hidden="true" class="anchor" id="requestkit"></a><a href="https://github.com/Rails-Designer/requestkit">Requestkit</a>
</h2>
<p>I have quietly published Requestkit: a local HTTP request toolkit for development. Test Stripe webhooks, GitHub hooks or any HTTP endpoint locally. Useful to see what payloads are sent out per endpoint. And soon you can use it to make webhook/api requests with it too.</p>
<h2>
<a href="#rails-icons" aria-hidden="true" class="anchor" id="rails-icons"></a><a href="https://github.com/Rails-Designer/rails_icons">Rails Icons</a>
</h2>
<p>Usage of Rails Icons is growing every day! Cool to see for such a humble, little gem. I pushed a new release recently to give you: <code>encoded_icon</code> and customisable animating icons.</p>
]]></content>
  </entry>
  <entry>
    <title>Update favicon with badge using custom turbo streams in Rails</title>
    <link href="https://railsdesigner.com/update-favicon-badge-turbo-stream/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-11-20T07:30:00Z</published>
    <updated>2025-11-20T07:30:00Z</updated>
    <id>https://railsdesigner.com/update-favicon-badge-turbo-stream/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/update-favicon-badge-turbo-stream/?ref=rss"><![CDATA[<p>In a <a href="https://railsdesigner.com/update-page-title-turbo/">previous article</a> I showed how to update the page title with a counter using custom Turbo Stream actions. That works great when you can see the tab. But what about when the tab is just one of many? Or pinned and showing only the favicon?</p>
<p>This article extends that solution by adding a visual badge to the favicon itself. Same approach, same clean API, just a different target.</p>
<p>As always <a href="https://github.com/rails-designer-repos/turbo-title">the code can be found on GitHub</a>. It will look something like this:</p>
<p><img src="/images/posts/unread-favicon.gif" alt="">{: class=”!max-w-[24rem]” :}</p>
<h2>
<a href="#creating-the-favicon-update-action" aria-hidden="true" class="anchor" id="creating-the-favicon-update-action"></a>Creating the favicon update action</h2>
<p>The favicon update follows the same pattern as the title counter. The view sets the initial favicon based on the message count:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> &lt;% content_for :title, @count.positive? ? "#{@count} • Messages" : "Messages" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;% content_for :favicon, @count.positive? ? "./icon-unread.svg" : "./icon.svg" %&gt;
</span><span style="color:#0c4a6e;"> &lt;%= turbo_stream_from :messages %&gt;
</span></code></pre>
<p>The layout uses this to set the favicon link:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> &lt;link rel="icon" href="/icon.png" type="image/png"&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;link rel="icon" href="/icon.svg" type="image/svg+xml"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;link rel="icon" href="&lt;%= yield(:favicon) || "/icon.svg" %&gt;" type="image/svg+xml"&gt;
</span><span style="color:#0c4a6e;"> &lt;link rel="apple-touch-icon" href="/icon.png"&gt;
</span></code></pre>
<p>In the Turbo Stream responses, the new custom action is called alongside the title counter:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> &lt;%= turbo_stream.prepend "messages", partial: @message %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;"> &lt;%= turbo_stream.set_title_counter @count %&gt;
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;%= turbo_stream.update_favicon @count %&gt;
</span></code></pre>
<p>Same for destroy:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> &lt;%= turbo_stream.remove @message %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;"> &lt;%= turbo_stream.set_title_counter @count %&gt;
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;%= turbo_stream.update_favicon @count %&gt;
</span></code></pre>
<p>See how <code>update_favicon</code> is called just like the <code>set_title_counter</code> action from the previous article. This is the same clean API.</p>
<p>Now create the helper method for the custom Turbo Stream tag:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> module TurboStreamActionsHelper
</span><span style="color:#0c4a6e;">   def set_title_counter(count, divider: nil)
</span><span style="color:#0c4a6e;">     turbo_stream_action_tag :set_title_counter, count: count, divider: divider
</span><span style="color:#0c4a6e;">   end
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def update_favicon(count)
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    turbo_stream_action_tag :update_favicon, count: count
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#0c4a6e;"> end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;"> Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
</span></code></pre>
<p>The <code>turbo_stream_action_tag</code> method generates an HTML tag with the action name and attributes. This produces a tag like:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">turbo-stream </span><span style="color:#0369a1;">action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">update_favicon</span><span style="color:#475569;">" </span><span style="color:#0369a1;">count</span><span style="color:#475569;">="</span><span style="color:#0369a1;">5</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">turbo-stream</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>When Turbo encounters this tag, it looks up the <code>update_favicon</code> function in <code>Turbo.StreamActions</code> and executes it. The JavaScript action is simple:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/turbo_stream_actions/update_favicon.js
</span><span style="font-weight:bold;color:#dc2626;">export default function</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">count</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">faviconLink </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">link[rel*='icon']</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">iconPath </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="font-weight:bold;color:#d97706;">0 </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">./icon-unread.svg</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">./icon.svg</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">faviconLink</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">href </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">iconPath
</span><span style="color:#475569;">}
</span></code></pre>
<p>This function reads the <code>count</code> attribute from the Turbo Stream tag. It finds the favicon link element in the document. Then it updates the <code>href</code> to either the unread icon or the default icon based on the count.</p>
<p>The logic is straightforward. If there are unread messages, show the badge icon. Otherwise show the default icon. The browser handles the rest.</p>
<p>Now register it with Turbo:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> import { Turbo } from "@hotwired/turbo-rails"
</span><span style="color:#0c4a6e;"> import set_title_counter from "turbo_stream_actions/set_title_counter"
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">import update_favicon from "turbo_stream_actions/update_favicon"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;"> Turbo.StreamActions.set_title_counter = set_title_counter
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">Turbo.StreamActions.update_favicon = update_favicon
</span></code></pre>
<p>The new custom action is now wired up and working. When messages are created or destroyed, the favicon updates automatically. This works for both direct user interactions and broadcasts from other users.</p>
<p>The favicon itself is just the default Rails favicon/icon that I adjusted with a blue badge—I know, not pretty, but it gets the job done:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">svg </span><span style="color:#0369a1;">width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">512</span><span style="color:#475569;">" </span><span style="color:#0369a1;">height</span><span style="color:#475569;">="</span><span style="color:#0369a1;">512</span><span style="color:#475569;">" </span><span style="color:#0369a1;">xmlns</span><span style="color:#475569;">="</span><span style="color:#0369a1;">http://www.w3.org/2000/svg</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">   </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">circle </span><span style="color:#0369a1;">cx</span><span style="color:#475569;">="</span><span style="color:#0369a1;">256</span><span style="color:#475569;">" </span><span style="color:#0369a1;">cy</span><span style="color:#475569;">="</span><span style="color:#0369a1;">256</span><span style="color:#475569;">" </span><span style="color:#0369a1;">r</span><span style="color:#475569;">="</span><span style="color:#0369a1;">256</span><span style="color:#475569;">" </span><span style="color:#0369a1;">fill</span><span style="color:#475569;">="</span><span style="color:#0369a1;">red</span><span style="color:#475569;">"/&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">   </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">circle </span><span style="color:#0369a1;">cx</span><span style="color:#475569;">="</span><span style="color:#0369a1;">382</span><span style="color:#475569;">" </span><span style="color:#0369a1;">cy</span><span style="color:#475569;">="</span><span style="color:#0369a1;">130</span><span style="color:#475569;">" </span><span style="color:#0369a1;">r</span><span style="color:#475569;">="</span><span style="color:#0369a1;">130</span><span style="color:#475569;">" </span><span style="color:#0369a1;">fill</span><span style="color:#475569;">="</span><span style="color:#0369a1;">blue</span><span style="color:#475569;">"/&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">svg</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Of course you should change it to match your favicon/icon. Typically a bright color for the badge works best!</p>
<hr>
<p>This favicon update complements the title counter from <a href="https://railsdesigner.com/update-page-title-turbo/">the previous article</a>. Adding even more actions is straightforward. You could add sound notifications, desktop notifications or any other browser API you need. The pattern stays the same. ❤️</p>
]]></content>
  </entry>
  <entry>
    <title>Inline editing with custom elements in Rails</title>
    <link href="https://railsdesigner.com/custom-element-inline-edit/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-11-13T07:30:00Z</published>
    <updated>2025-11-13T07:30:00Z</updated>
    <id>https://railsdesigner.com/custom-element-inline-edit/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/custom-element-inline-edit/?ref=rss"><![CDATA[<p>How would you tackle this feature in a typical Hotwired Rails app: a HTML-element (like this <code>h1</code>) gets editable on click and when focus is removed, the record is updated.</p>
<p><img src="/images/posts/editable-content.gif" alt="Inline editing demo"></p>
<p>How about I tell you, it is done using just this HTML:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">editable-content </span><span style="color:#0369a1;">url</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> post_path</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">post</span><span style="color:#475569;">) %&gt;"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h1 </span><span style="color:#0369a1;">name</span><span style="color:#475569;">="</span><span style="color:#0369a1;">post[title]</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%= @</span><span style="color:#1e293b;">post</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">title </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h1</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">editable-content</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> simple_format </span><span style="color:#475569;">@</span><span style="color:#1e293b;">post</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">content </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Yes! 🤯 It is made possible using a custom element: <code>editable-content</code>. It is part of a little experimentation phase of custom elements I am currently in.</p>
<p>In this article I want to show how you can create such a custom element. As always the <a href="https://github.com/rails-designer-repos/editable-custom-element">code can be found here</a>.</p>
<h2>
<a href="#what-are-custom-elements" aria-hidden="true" class="anchor" id="what-are-custom-elements"></a>What are custom elements?</h2>
<p>But first: what are custom elements? If you are familiar in this place on the web, you have seen them mentioned before. If not: custom elements are part of the Web Components standard. They let you define your own HTML tags with custom behavior. The beauty is that they are just HTML. You can style them with CSS, use them in your templates and they work without any framework. They have lifecycle callbacks like <code>connectedCallback</code> (when the element is added to the page) and <code>disconnectedCallback</code> (when removed) that make them perfect for encapsulating interactive behavior. Similar to Stimulus controllers. An article that does a deep(er) dive into them is scheduled. 🤫</p>
<p>For this inline editing feature, the custom element handles everything: detecting clicks, creating input fields, saving changes and restoring the display. All wrapped in a clean HTML tag.</p>
<h2>
<a href="#setting-up-the-custom-element" aria-hidden="true" class="anchor" id="setting-up-the-custom-element"></a>Setting up the custom element</h2>
<p>Start by creating the JavaScript file for the custom element:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/components/editable_content.js
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">EditableContent </span><span style="font-weight:bold;color:#dc2626;">extends </span><span style="color:#0c4a6e;">HTMLElement </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">connectedCallback</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">click</span><span style="color:#475569;">", (</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">edit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">));
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">customElements</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">("</span><span style="color:#0369a1;">editable-content</span><span style="color:#475569;">", </span><span style="color:#1e293b;">EditableContent</span><span style="color:#475569;">);
</span></code></pre>
<p>The <code>connectedCallback</code> runs when the element is added to the page. It sets up a click listener that triggers the editing flow. The <code>customElements.define</code> call registers the new tag name with the browser.</p>
<p>Import it in your application JavaScript:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> import "@hotwired/turbo-rails"
</span><span style="color:#0c4a6e;"> import "controllers"
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">import "components/editable_content"
</span></code></pre>
<p>And configure importmap to find the new directory:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> pin_all_from "app/javascript/controllers", under: "controllers"
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">pin_all_from "app/javascript/components", under: "components"
</span></code></pre>
<p>Now you can use <code>&lt;editable-content&gt;</code> tags in your views. 🥳</p>
<h2>
<a href="#making-elements-editable" aria-hidden="true" class="anchor" id="making-elements-editable"></a>Making elements editable</h2>
<p>The <code>#edit</code> method handles the click event and determines what should become editable:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">edit</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">editable</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#1e293b;">this </span><span style="font-weight:bold;color:#0369a1;">|| !</span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">hasAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">name</span><span style="color:#475569;">")) </span><span style="font-weight:bold;color:#dc2626;">return</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">create</span><span style="color:#475569;">(</span><span style="color:#1e293b;">target</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">wrapper </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">wrap</span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">original </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">target</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">replaceWith</span><span style="color:#475569;">(</span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0284c7;">focus</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">blur</span><span style="color:#475569;">", () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">save</span><span style="color:#475569;">(</span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">, </span><span style="color:#1e293b;">field</span><span style="color:#475569;">));
</span><span style="color:#475569;">}
</span></code></pre>
<p>First it finds the actual editable element using <code>#editable</code>. This method walks up the DOM tree until it finds a direct child of the custom element. This means you can click anywhere inside an element (like in the middle of a heading) and the whole element becomes editable.</p>
<p>The method checks for a <code>name</code> attribute. This is required because it tells the server which field to update. Without it, the element is not editable.</p>
<p>Then it creates an input field, wraps it and replaces the original element. The wrapper stores a reference to the original element so it can be restored later. Finally it focuses the field and sets up a blur listener to save changes when the user clicks away.</p>
<h2>
<a href="#creating-the-input-field" aria-hidden="true" class="anchor" id="creating-the-input-field"></a>Creating the input field</h2>
<p>The <code>#create</code> method builds the appropriate input element:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">create</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">field </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0284c7;">createElement</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">isMultiline</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">textarea</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">input</span><span style="color:#475569;">");
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">isMultiline</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">)) </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">type </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">text</span><span style="color:#475569;">";
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">className </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">className</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent</span><span style="color:#475569;">.</span><span style="color:#1e293b;">trim</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">name </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">name</span><span style="color:#475569;">");
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">click</span><span style="color:#475569;">", (</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stopPropagation</span><span style="color:#475569;">());
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">field</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span></code></pre>
<p>It decides between a textarea and input based on the element type. Block-level elements like paragraphs get textareas. Inline elements like headings get text inputs. The field inherits the original element’s classes so your CSS styling carries over. The click listener prevents the field from triggering another edit when clicked.</p>
<p>The <code>#isMultiline</code> helper checks the tag name:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">isMultiline</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#475569;">["</span><span style="color:#0369a1;">p</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">div</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">blockquote</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">pre</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">article</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">section</span><span style="color:#475569;">"].</span><span style="color:#1e293b;">includes</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">tagName</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toLowerCase</span><span style="color:#475569;">());
</span><span style="color:#475569;">}
</span></code></pre>
<h2>
<a href="#saving-changes" aria-hidden="true" class="anchor" id="saving-changes"></a>Saving changes</h2>
<p>When the field loses focus, the <code>#save</code> method sends the update to the server:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">async</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">save</span><span style="color:#475569;">(</span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">, </span><span style="color:#1e293b;">field</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">formData </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">FormData</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">formData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">append</span><span style="color:#475569;">(</span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">name</span><span style="color:#475569;">, </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">response </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">await fetch</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#url</span><span style="color:#475569;">, {
</span><span style="color:#0c4a6e;">    method</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">PATCH</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">    headers</span><span style="color:#475569;">: { "</span><span style="color:#0369a1;">X-CSRF-Token</span><span style="color:#475569;">": </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">csrfToken </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">    body</span><span style="color:#475569;">: </span><span style="color:#1e293b;">formData
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">});
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">response</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">ok</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">display </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">original</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">display</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">wrapper</span><span style="color:#475569;">.</span><span style="color:#1e293b;">replaceWith</span><span style="color:#475569;">(</span><span style="color:#1e293b;">display</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p>It builds a FormData object with the field name and value (to matches Rails’ parameter format). The fetch request includes the CSRF token for security. If the save succeeds, it updates the original element with the new value and swaps it back in place.</p>
<h2>
<a href="#limitations-and-extensions" aria-hidden="true" class="anchor" id="limitations-and-extensions"></a>Limitations and extensions</h2>
<p>This implementation keeps things simple. It only handles text content without new lines. For rich text you would need a different approach. But for titles, labels and short descriptions it works great.</p>
<p>You can make fields more clearly editable with CSS. Add a hover effect or an edit icon to signal interactivity. For feedback on successful saves, you could add flash notifications or a subtle CSS animation on the field. The custom element makes it easy to extend with these features.</p>
<p>The beauty of custom elements is that they are just HTML. You can nest them, style them and combine them with other components. They work with Turbo, Stimulus and any other JavaScript you have. And because they use standard browser APIs, they are fast and reliable.</p>
<p>How do you like this approach?</p>
]]></content>
  </entry>
  <entry>
    <title>Update page title counter with custom turbo streams in Rails</title>
    <link href="https://railsdesigner.com/update-page-title-turbo/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-11-06T07:30:00Z</published>
    <updated>2025-11-06T07:30:00Z</updated>
    <id>https://railsdesigner.com/update-page-title-turbo/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/update-page-title-turbo/?ref=rss"><![CDATA[<p>A few weeks ago <a href="https://railsdesigner.com/saas/month/">I helped someone start their first SaaS</a>. It was a really cool, small problem for a specific niche that would made for a great small business. They already are serving the first handful of customers. But I digress… The main view of the app was a list of records. And as the app would be perfect to have in your “pinned tabs”, a counter with new records was a good feature to add.</p>
<p>This article goes over how easy this can be done with a (custom) Turbo Stream in Rails. I have written about <a href="https://railsdesigner.com/progress-bar-turbo/">custom turbo streams before</a>, but did not touch upon how to cleanly write them yourself.</p>
<p>It will look something like this:<br>
<img src="/images/posts/turbo-title.gif" alt="">{: class=”!max-w-[24rem]” :}</p>
<p>Notice how the title updates with the message count?</p>
<p>As always <a href="https://github.com/rails-designer-repos/turbo-title">the code can be found on GitHub</a>.</p>
<h2>
<a href="#creating-a-custom-turbo-stream-action" aria-hidden="true" class="anchor" id="creating-a-custom-turbo-stream-action"></a>Creating a custom turbo stream action</h2>
<p>To demonstrate the title counter, I created a simple message system in the repo. This is just scaffolding to show the counter in action, so no need to go over it.</p>
<p>The goal now is to update the page title with a counter whenever messages are created or destroyed. Rails ships with several built-in Turbo Stream actions like <code>append</code>, <code>prepend</code>, <code>replace</code> and <code>remove</code>. But updating the page title requires a custom action.</p>
<p>Starting from the outside, the index view sets the initial title and displays the message count:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;% content_for :title, "Messages" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;% content_for :title, @count.positive? ? "#{@count} • Messages" : "Messages" %&gt;
</span></code></pre>
<p>The controller provides the count:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> def index
</span><span style="color:#0c4a6e;">   @messages = Message.all.reverse
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  @count = @messages.count
</span><span style="color:#0c4a6e;"> end
</span></code></pre>
<p>In the Turbo Stream responses, the custom action is called alongside the standard ones:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/messages/create.turbo_stream.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">prepend </span><span style="color:#475569;">"</span><span style="color:#0369a1;">messages</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">partial</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">message </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">set_title_counter </span><span style="color:#475569;">@</span><span style="color:#1e293b;">count </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">
</span><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/messages/destroy.turbo_stream.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">remove </span><span style="color:#475569;">@</span><span style="color:#1e293b;">message </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">set_title_counter </span><span style="color:#475569;">@</span><span style="color:#1e293b;">count </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>See how <code>set_title_counter</code> is called just like any built-in Turbo Stream action. This is the clean API I am looking for.</p>
<p>Now create the helper method for the custom Turbo Stream tag:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/helpers/turbo_stream_actions_helper.rb
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">TurboStreamActionsHelper
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">set_title_counter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">count</span><span style="color:#475569;">, </span><span style="color:#1e293b;">divider</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    turbo_stream_action_tag </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">set_title_counter</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">count</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> count</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">divider</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> divider
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">Turbo</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Streams</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">TagBuilder</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">prepend</span><span style="color:#475569;">(</span><span style="color:#1e293b;">TurboStreamActionsHelper</span><span style="color:#475569;">)
</span></code></pre>
<p>The <code>turbo_stream_action_tag</code> method is Rails’ way of creating custom Turbo Stream actions. It generates an HTML tag with the action name and any attributes you pass. The <code>prepend</code> line makes this helper available on the <code>turbo_stream</code> object in views.</p>
<h3>
<a href="#the-turbo-stream-tag" aria-hidden="true" class="anchor" id="the-turbo-stream-tag"></a>The turbo stream tag</h3>
<p>Before diving into the JavaScript part of the custom Turbo Stream action, it helps to understand what HTML this helper generates. When you call <code>turbo_stream.set_title_counter @count</code>, it produces a tag like this:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">turbo-stream </span><span style="color:#0369a1;">action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">set_title_counter</span><span style="color:#475569;">" </span><span style="color:#0369a1;">count</span><span style="color:#475569;">="</span><span style="color:#0369a1;">5</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">template</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">turbo-stream</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>This is the same structure as built-in Turbo Stream actions. The <code>action</code> attribute tells Turbo which JavaScript function to call (created next). The <code>count</code> attribute is custom data that the JavaScript action reads. The empty <code>&lt;template&gt;</code> tag is required by the Turbo Stream format, even though this action does not insert any HTML into the page.</p>
<p>When Turbo encounters this tag, it looks up the <code>set_title_counter</code> function in <code>Turbo.StreamActions</code> and executes it. It looks like this:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/turbo_stream_actions/set_title_counter.js
</span><span style="font-weight:bold;color:#dc2626;">export default function</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">count</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="font-weight:bold;color:#d97706;">0
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">divider </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">("</span><span style="color:#0369a1;">divider</span><span style="color:#475569;">") </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#475569;">"</span><span style="color:#0369a1;">•</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">title </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">title
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">baseTitle </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">title</span><span style="color:#475569;">.</span><span style="color:#1e293b;">includes</span><span style="color:#475569;">(</span><span style="color:#1e293b;">divider</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#1e293b;">title</span><span style="color:#475569;">.</span><span style="color:#1e293b;">split</span><span style="color:#475569;">(</span><span style="color:#1e293b;">divider</span><span style="color:#475569;">).</span><span style="color:#0284c7;">pop</span><span style="color:#475569;">().</span><span style="color:#1e293b;">trim</span><span style="color:#475569;">() </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#1e293b;">title
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">title </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">count </span><span style="font-weight:bold;color:#0369a1;">&gt; </span><span style="font-weight:bold;color:#d97706;">0 </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">count</span><span style="color:#475569;">} ${</span><span style="color:#1e293b;">divider</span><span style="color:#475569;">} ${</span><span style="color:#1e293b;">baseTitle</span><span style="color:#475569;">}` </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#1e293b;">baseTitle
</span><span style="color:#475569;">}
</span></code></pre>
<p>This function reads the <code>count</code> and <code>divider</code> attributes from the Turbo Stream tag. It then extracts the base title by removing any existing counter. If the count is greater than zero, it prepends the count with the divider. Otherwise just the base title.</p>
<p>The logic for extracting the base title is important. It checks if the current title already contains a counter by looking for the divider. If found, it splits on the divider and takes the last part. This ensures the counter does not stack up with repeated updates.</p>
<p>Now to make it work, lets register it with Turbo:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> app/javascript/turbo_stream_actions/index.js
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Turbo </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/turbo-rails</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#1e293b;">set_title_counter </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">turbo_stream_actions/set_title_counter</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">Turbo</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">StreamActions</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">set_title_counter </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">set_title_counter
</span></code></pre>
<p>Import in the main application file:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> import "@hotwired/turbo-rails"
</span><span style="color:#0c4a6e;"> import "controllers"
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">import "turbo_stream_actions"
</span></code></pre>
<p>And let importmap know about the new directory:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> pin_all_from "app/javascript/controllers", under: "controllers"
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
</span></code></pre>
<p>(this is importmap only and not something needed if you use NPM)</p>
<p>Then finally make sure the controller actions to provide count (<code>@count = Message.all.count</code>). Now the new custom action is all wired up and working as seen in the GIF above. Yay!</p>
<p>The new custom Turbo Stream action works great for direct user interactions. But what about updates from other users or background jobs? Turbo Stream broadcasts handle this just as well. The <code>Message</code> model broadcasts changes and triggers the title counter update:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> class Message &lt; ApplicationRecord
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  broadcasts :messages, inserts_by: :prepend
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  after_commit :set_title_counter
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">   def title = "Message #{id}"
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  private
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def set_title_counter
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    broadcast_action_to :messages, action: :set_title_counter, attributes: { count: Message.count }
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#0c4a6e;"> end
</span></code></pre>
<p>The <code>broadcasts</code> line handles the standard create, update (not shown in the repo, but could be used to mark a message as “read”) and destroy broadcasts. The <code>after_commit</code> callback sends the custom title counter update after any database change.</p>
<p>The <code>broadcast_action_to</code> method is similar to the helper created earlier. It sends a Turbo Stream action over the broadcast channel. The <code>attributes</code> hash becomes the HTML attributes on the tag, which the JavaScript action reads.</p>
<p>And lastly subscribe to the broadcast in the view:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"> &lt;% content_for :title, @count.positive? ? "#{@count} • Messages" : "Messages" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;%= turbo_stream_from :messages %&gt;
</span></code></pre>
<p>Now when any user creates or deletes a message, all connected clients see their title counter update in real-time.</p>
<hr>
<p>This new custom action integrates nicely with Rails’ Turbo Streams. It works in direct responses and also broadcasts. And because it is just JavaScript, you can extend it with animations, notifications or any other browser API you need. Cool, right?</p>
]]></content>
  </entry>
  <entry>
    <title>Two products, one Rails codebase</title>
    <link href="https://railsdesigner.com/one-codebase-two-products/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-11-05T07:30:00Z</published>
    <updated>2025-11-05T07:30:00Z</updated>
    <id>https://railsdesigner.com/one-codebase-two-products/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/one-codebase-two-products/?ref=rss"><![CDATA[<p>(apologies for the title, but couldn’t resist 💩)</p>
<p>Some time ago I <a href="https://railsdesigner.com/saas/month/">helped a new client</a> <strong>launch two (and more in the future) products with one Rails code base</strong>. Due to legal and commercial considerations I cannot link directly to any of the two products, but you can think of it as “discussion forums for specific niches”. The client is well versed in SEO and once they spot an interesting niche, can spin up another platform in no time. Cool, right?</p>
<p>If you do not have the right marketing chops, I do not recommend you take this approach, but some other ideas that can use a similar code set up that come to mind are:</p>
<ul>
<li>Marketplace</li>
<li>Business Directory</li>
<li>Dating Site</li>
<li>Directory sites</li>
</ul>
<p>I took on the opportunity because from a technical point of view it was interesting. One code base that could be deployed separately (different apps or servers even!) and be served under its own domain, sporting their own unique branding and sometimes different views.</p>
<p>In this short article I want to show what (Rails-)specific features I used for this and how you can build something like this yourself. As always the <a href="https://github.com/rails-designer-repos/multiple">code can be found on GitHub</a>. It uses a fictitious example of a <em>Rails Designer</em> and <em>Laravel Designer</em> sites.</p>
<p>The two “product’s homepages” will look like this:</p>
<p><img src="/images/saas_posts/one-codebase-rd.jpg" alt=""></p>
<p><img src="/images/saas_posts/one-codebase-rl.jpg" alt=""></p>
<p>(yes, not pretty—but notices how they are similar but quite different?)</p>
<h2>
<a href="#configuration-object" aria-hidden="true" class="anchor" id="configuration-object"></a>Configuration Object</h2>
<p>First thing I needed was a way to configure each product. A simple <code>Config</code> module loads YAML files from <code>config/configurations/</code> and turns them into constants. I <a href="https://railsdesigner.com/saas/configuration/">wrote about this pattern before</a>, so check it out for all the details. it works with context-specific yaml files stored in <code>config/configurations</code>. Like this one for for the app-specific settings:</p>
<pre lang="yaml" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> config/configurations/app.yml
</span><span style="font-weight:bold;color:#dc2626;">shared</span><span style="color:#475569;">:
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">variant</span><span style="color:#475569;">: </span><span style="color:#0369a1;">&lt;%= ENV.fetch("APP_VARIANT", "rails_designer") %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">name</span><span style="color:#475569;">: </span><span style="color:#0369a1;">&lt;%= ENV.fetch("APP_NAME", "Rails Designer") %&gt;
</span></code></pre>
<p>This is the key part. All product-specific configuration is driven by environment variables. When deploying, you set <code>APP_VARIANT=laravel_designer</code> for one product and <code>APP_VARIANT=rails_designer</code> for the other. This makes it super easy to spin up new products without touching the code. Just set different environment variables for each deployment.</p>
<p>The <code>app.yml</code> file can grow to include more settings like feature flags, API keys, or any other product-specific configuration. For example, you might have different email templates, or different third-party integrations per product. All of this can be configured through environment variables and this YAML file. The beauty is that you can access these values anywhere in the app with <code>Config::App.variant</code> and <code>Config::App.name</code>. Simple and clean.</p>
<h2>
<a href="#css-setup" aria-hidden="true" class="anchor" id="css-setup"></a>CSS Setup</h2>
<p>For the styling, each product needs its own look and feel. The setup starts with a base <code>application.css</code> file with shared styles:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">/*</span><span style="font-style:italic;color:#64748b;"> app/assets/stylesheets/application.css </span><span style="font-style:italic;color:#475569;">*/
</span><span style="color:#0c4a6e;">@layer reset, </span><span style="font-weight:bold;color:#dc2626;">base</span><span style="color:#0c4a6e;">, components, utilities;
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#475569;">@</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#0284c7;">url</span><span style="color:#475569;">("</span><span style="color:#0369a1;">./normalize.css</span><span style="color:#475569;">")</span><span style="color:#0c4a6e;"> layer</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">reset</span><span style="color:#475569;">);
</span><span style="font-weight:bold;color:#475569;">@</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#0284c7;">url</span><span style="color:#475569;">("</span><span style="color:#0369a1;">./components/nav.css</span><span style="color:#475569;">")</span><span style="color:#0c4a6e;"> layer</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">components</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">@layer components </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  .hero {
</span><span style="color:#0c4a6e;">    display</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">flex</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    flex-direction</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">column</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    align-items</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">center</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    justify-content</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">center</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    width</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">100</span><span style="font-weight:bold;color:#dc2626;">%</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    padding-block</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">12</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    * {
</span><span style="color:#0c4a6e;">      margin-block</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">h1 </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      font-size</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">3</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">      letter-spacing</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">-.025</span><span style="font-weight:bold;color:#dc2626;">em</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      margin-block-start</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">.5</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">      font-size</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1.875</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>CSS layers control the cascade here. The <code>reset</code> layer contains normalize.css, <code>base</code> is for base styles, <code>components</code> for component styles and <code>utilities</code> for utility classes. This way styles can be overridden in a predictable way.</p>
<p>Product-specific CSS files set CSS custom properties that the shared components use:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">/*</span><span style="font-style:italic;color:#64748b;"> app/assets/stylesheets/rails_designer.css </span><span style="font-style:italic;color:#475569;">*/
</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">root </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#475569;">#</span><span style="font-weight:bold;color:#075985;">dc2626</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">@layer </span><span style="font-weight:bold;color:#dc2626;">base </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  a</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">any-link {
</span><span style="color:#0c4a6e;">    text-decoration-line: none</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    color</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">inherit</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    &amp;</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">not([class]) {
</span><span style="color:#0c4a6e;">      text-decoration-line</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">underline</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">      color</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      &amp;</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">hover {
</span><span style="color:#0c4a6e;">        text-decoration-line: none</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">    }
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">@layer components </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  nav a {
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">nav-item-background-color</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">nav-item-color</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#475569;">#</span><span style="font-weight:bold;color:#075985;">fff</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">nav-item-border-radius</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">.5</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">/*</span><span style="font-style:italic;color:#64748b;"> app/assets/stylesheets/laravel_designer.css </span><span style="font-style:italic;color:#475569;">*/
</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">root </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#475569;">#</span><span style="font-weight:bold;color:#075985;">f97316</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">@layer components </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  nav a {
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">nav-item-color</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>The navigation component show an example how to use these custom properties:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">/*</span><span style="font-style:italic;color:#64748b;"> app/assets/stylesheets/components/nav.css </span><span style="font-style:italic;color:#475569;">*/
</span><span style="font-weight:bold;color:#dc2626;">nav </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  display</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">flex</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  align-items</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">center</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  justify-content</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">center</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  column-gap</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  margin-block-start</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">.5</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  a {
</span><span style="color:#0c4a6e;">    display</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">inline flex</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    padding-block</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">.5</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    padding-inline</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">1</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    font-weight; 600;
</span><span style="color:#0c4a6e;">    color</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">nav-item-color</span><span style="color:#475569;">, --</span><span style="color:#0c4a6e;">primary-color</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">    text-decoration-line</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">none</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">    background-color</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">nav-item-background-color</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">    border-radius</span><span style="color:#475569;">: </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">nav-item-border-radius</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>In the layout, both the shared <code>application.css</code> and the product-specific CSS file get loaded:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/layouts/application.html.erb
</span><span style="color:#0c4a6e;">&lt;!DOCTYPE html&gt;
</span><span style="color:#0c4a6e;">&lt;html&gt;
</span><span style="color:#0c4a6e;">  &lt;head&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">    &lt;title&gt;&lt;%= content_for(:title) || "Rails Designer" %&gt;&lt;/title&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    &lt;title&gt;&lt;%= content_for(:title) || Config::App.name %&gt;&lt;/title&gt;
</span><span style="color:#0c4a6e;">    &lt;meta name="viewport" content="width=device-width,initial-scale=1"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= csrf_meta_tags %&gt;
</span><span style="color:#0c4a6e;">    &lt;%= csp_meta_tag %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    &lt;link rel="icon" href="/icon.svg" type="image/svg+xml"&gt;
</span><span style="color:#0c4a6e;">    &lt;link rel="apple-touch-icon" href="/icon.png"&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">    &lt;%# Includes all stylesheet files in app/assets/stylesheets %&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">    &lt;%= stylesheet_link_tag :app, "data-turbo-track": "reload" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    &lt;%= stylesheet_link_tag "application", Config::App.variant, "data-turbo-track": "reload" %&gt;
</span><span style="color:#0c4a6e;">    &lt;%= javascript_importmap_tags %&gt;
</span><span style="color:#0c4a6e;">  &lt;/head&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  &lt;body&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    &lt;%= render "shared/navigation" %&gt;
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">    &lt;%= yield %&gt;
</span><span style="color:#0c4a6e;">  &lt;/body&gt;
</span><span style="color:#0c4a6e;">&lt;/html&gt;
</span></code></pre>
<p>This loads <code>application.css</code> first, then either <code>rails_designer.css</code> or <code>laravel_designer.css</code> depending on the <code>APP_VARIANT</code> environment variable. The product-specific CSS can override the shared styles using CSS custom properties or by targeting the same selectors in higher layers.</p>
<h2>
<a href="#rails-variants" aria-hidden="true" class="anchor" id="rails-variants"></a>Rails Variants</h2>
<p>Now this is all powered by a really cool Rails feature called <a href="https://guides.rubyonrails.org/layouts_and_rendering.html#the-variants-option">variants</a> that lets you render different templates based on a variant. This is typically used for mobile vs. desktop views, but works perfectly for different products too.</p>
<p>A concern sets the variant based on the configuration:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/controllers/concerns/variants.rb
</span><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">Variants
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">extend </span><span style="color:#0c4a6e;">ActiveSupport</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Concern
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">included </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">    before_action </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">set_variant
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">set_variant
</span><span style="color:#0c4a6e;">    request</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">variant </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Config</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">App</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">variant</span><span style="font-weight:bold;color:#0369a1;">&amp;</span><span style="color:#475569;">.</span><span style="color:#0284c7;">to_sym
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Including this in the <code>ApplicationController</code> makes it available everywhere:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/controllers/application_controller.rb
</span><span style="color:#0c4a6e;">class ApplicationController &lt; ActionController::Base
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  include Variants
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>With this in place, variant-specific views can be created using the <code>+variant</code> syntax. Different navigation partials for each product:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/shared/_navigation.html+rails_designer.erb %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">nav</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">About Rails</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">#</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Design for Rails</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">#</span><span style="color:#475569;">" %&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">nav</span><span style="color:#475569;">&gt;
</span></code></pre>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/shared/_navigation.html+laravel_designer.erb %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">nav</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">About Laravel</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">#</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Design for Laravel</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">#</span><span style="color:#475569;">" %&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">nav</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>And different homepage views:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/pages/show.html+rails_designer.erb %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">section </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">hero</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h1</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    Welcome to Rails Designer
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h1</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Check out </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">railsdesigner.com</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">https://railsdesigner.com/</span><span style="color:#475569;">" %&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">section</span><span style="color:#475569;">&gt;
</span></code></pre>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/pages/show.html+laravel_designer.erb %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">section </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">hero</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">h1</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    Welcome to Laravel Designer
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">h1</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">section</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>When Rails renders a view, it first looks for a variant-specific template. If it finds one, it uses that. Otherwise, it falls back to the default template. This means you only need to create variant-specific views when they actually differ between products. Shared views can stay as regular templates.</p>
<hr>
<p>And that is the gist of how I helped this client build this business for them. It is now multiple months live and it works great! Each product gets its own branding, its own views where needed and its own configuration. But you only maintain one codebase, which makes adding features and fixing bugs much easier.</p>
<p>Does this spark some interesting ideas to you for a new business? Let me know! 😊</p>
]]></content>
  </entry>
  <entry>
    <title>Extending the Kanban board (using Rails and Hotwire)</title>
    <link href="https://railsdesigner.com/extending-kanban-rails-hotwire/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-10-30T07:30:00Z</published>
    <updated>2026-01-05T13:45:00+00:00</updated>
    <id>https://railsdesigner.com/extending-kanban-rails-hotwire/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/extending-kanban-rails-hotwire/?ref=rss"><![CDATA[<p>In my <a href="https://railsdesigner.com/kanban-rails-hotwire/">previous article about building a Kanban board with Rails and Hotwire</a>, I showed how to create a Kanban board using a Stimulus controller with less than 30 lines of code. But what good is a Kanban board if you can’t actually add new cards and columns? Let’s fix that.</p>
<p>In this follow-up, I will walk you through three key enhancements that build on top of the previous implementation. The <a href="https://github.com/rails-designer-repos/cards">code is available on GitHub</a> and these commits progressively add more functionality to make the board truly useful.</p>
<p><img src="/images/posts/extended-kanban.gif" alt=""></p>
<h2>
<a href="#adding-new-cards-and-columns" aria-hidden="true" class="anchor" id="adding-new-cards-and-columns"></a>Adding New Cards and Columns</h2>
<p>First up is the ability to create new cards within any column. This is surprisingly straightforward with Turbo Streams.</p>
<p>I started by adding a <code>create</code> action to the <code>CardsController</code>:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">class CardsController &lt; ApplicationController
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def create
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    @card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">  def update
</span><span style="color:#0c4a6e;">    Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
</span><span style="color:#0c4a6e;">  end
</span></code></pre>
<p>Then updated the column partial to include a button at the bottom:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/boards/_column.html.erb
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;li draggable data-sortable-id-value="&lt;%= column.id %&gt;"class="w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;li draggable data-sortable-id-value="&lt;%= column.id %&gt;"class="relative w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto"&gt;
</span><span style="color:#0c4a6e;">  &lt;h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= column.name %&gt;
</span><span style="color:#0c4a6e;">  &lt;/h2&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  &lt;ul data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="&lt;%= column.id %&gt;" data-sortable-endpoint-value="&lt;%= card_path(id: "__ID__") %&gt;" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(column) %&gt;" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="&lt;%= column.id %&gt;" data-sortable-endpoint-value="&lt;%= card_path(id: "__ID__") %&gt;" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= render column.cards %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    &lt;li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block"&gt;
</span><span style="color:#0c4a6e;">      &lt;p class="text-sm font-normal text-gray-600"&gt;No cards here…&lt;/p&gt;
</span><span style="color:#0c4a6e;">    &lt;/li&gt;
</span><span style="color:#0c4a6e;">  &lt;/ul&gt;
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;div class="sticky bottom-0 px-4 py-2 bg-gray-100/90"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    &lt;%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;/div&gt;
</span><span style="color:#0c4a6e;">&lt;/li&gt;
</span></code></pre>
<p>Notice I added an <code>id</code> to the <code>ul</code> element using <code>dom_id(column)</code>. This is needed for the Turbo Stream response to know where to append the new card. This Turbo Stream response is beautifully simple:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/cards/create.turbo_stream.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">append </span><span style="color:#475569;">@</span><span style="color:#1e293b;">card</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">column</span><span style="color:#475569;">, @</span><span style="color:#1e293b;">card </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Do not forget to update the routes:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># config/routes.rb
</span><span style="color:#0c4a6e;">Rails.application.routes.draw do
</span><span style="color:#0c4a6e;">  resources :columns, only: %w[update]
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  resources :cards, only: %w[update]
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  resources :cards, only: %w[create update]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  root to: "pages#show"
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>Now when you click the button, a new card appears instantly at the bottom of the column. No page refresh needed. This is Hotwire at its finest.</p>
<p>The same pattern works for adding new columns. Add a <code>create</code> action to the <code>ColumnsController</code>:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">class ColumnsController &lt; ApplicationController
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  def create
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    @column = Board::Column.create board_id: params[:board_id], name: "New column"
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">  def update
</span><span style="color:#0c4a6e;">    Board::Column.find(params[:id]).update position: new_position
</span><span style="color:#0c4a6e;">  end
</span></code></pre>
<p>The board partial needed a bit of restructuring to add the new column button:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/boards/_board.html.erb
</span><span style="color:#0c4a6e;">&lt;h1 class="mx-4 mt-2 text-lg font-bold text-gray-800"&gt;
</span><span style="color:#0c4a6e;">  &lt;%= board.name %&gt;
</span><span style="color:#0c4a6e;">&lt;/h1&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;ul data-controller="sortable" data-sortable-endpoint-value="&lt;%= column_path(id: "__ID__") %&gt;" class="flex w-full gap-x-8 overflow-x-auto mt-2 px-4"&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  &lt;%= render board.columns %&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;/ul&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(board) %&gt;" data-controller="sortable" data-sortable-endpoint-value="&lt;%= column_path(id: "__ID__") %&gt;" class="flex gap-x-8"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    &lt;%= render board.columns %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;/ul&gt;
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;/div&gt;
</span></code></pre>
<p>I also introduced a CSS custom property <code>--column-height</code> to keep the height consistent across columns and the button. The vertical text for the button is a nice touch that saves horizontal space. The <code>writing-mode: vertical-lr</code> with <code>rotate-180</code> flips the text (without <code>writing-mode</code> the button still takes up it required space) so it reads from bottom to top, which feels more natural when placed on the right side of the board. I updated the column partial to use this new CSS variable:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/boards/_column.html.erb
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">&lt;li draggable data-sortable-id-value="&lt;%= column.id %&gt;"class="relative w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">&lt;li draggable data-sortable-id-value="&lt;%= column.id %&gt;"class="relative w-80 h-[var(--column-height)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto"&gt;
</span><span style="color:#0c4a6e;">  &lt;h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= column.name %&gt;
</span><span style="color:#0c4a6e;">  &lt;/h2&gt;
</span></code></pre>
<p>The Turbo Stream response follows the same pattern:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">&lt;%#</span><span style="font-style:italic;color:#64748b;"> app/views/columns/create.turbo_stream.erb %&gt;
</span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_stream</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">append </span><span style="color:#475569;">@</span><span style="color:#1e293b;">column</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">board</span><span style="color:#475569;">, @</span><span style="color:#1e293b;">column </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>And the routes update:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># config/routes.rb
</span><span style="color:#0c4a6e;">Rails.application.routes.draw do
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  resources :columns, only: %w[update]
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  resources :columns, only: %w[create update]
</span><span style="color:#0c4a6e;">  resources :cards, only: %w[create update]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  root to: "pages#show"
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>All basic Rails + Hotwire love, right?</p>
<h2>
<a href="#multi-select-drag-and-drop" aria-hidden="true" class="anchor" id="multi-select-drag-and-drop"></a>Multi-Select Drag and Drop</h2>
<p>This is where things get more interesting. What if you need to move multiple cards at once? SortableJS has a MultiDrag plugin that handles this beautifully.</p>
<p>First, I refactored the repositioning logic into a dedicated controller. This makes sense because both cards and columns need the same repositioning behavior and I wanted to handle multiple items at once:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> app/controllers/reposition_controller.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">RepositionController </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationController
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">update
</span><span style="color:#0c4a6e;">    resources</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">each_with_index </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">resource</span><span style="color:#475569;">, </span><span style="color:#1e293b;">index</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">      resource</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">update!</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">        </span><span style="font-weight:bold;color:#075985;">board_column_id</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">board_column_id</span><span style="color:#475569;">],
</span><span style="color:#0c4a6e;">        </span><span style="font-weight:bold;color:#075985;">position</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">new_position</span><span style="color:#475569;">].</span><span style="color:#0284c7;">to_i
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">}.</span><span style="color:#0c4a6e;">compact_blank</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  RESOURCES </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">board/columns</span><span style="color:#475569;">" =&gt; </span><span style="color:#0c4a6e;">Board</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Column</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">"</span><span style="color:#0369a1;">board/cards</span><span style="color:#475569;">" =&gt; </span><span style="color:#0c4a6e;">Board</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Card
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">resources
</span><span style="color:#0c4a6e;">    resource_class </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">RESOURCES</span><span style="color:#475569;">[</span><span style="color:#0c4a6e;">params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">resource_name</span><span style="color:#475569;">]]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    resource_class</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">where</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0284c7;">Array</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">params</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">ids</span><span style="color:#475569;">]))
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>This controller is generic enough to handle both cards and columns. It takes an array of IDs and updates them all in one go. If you need to support more/other resources, just extend the <code>RESOURCES</code> hash.</p>
<p>Now I could remove the <code>update</code> actions from both the <code>CardsController</code> and <code>ColumnsController</code>:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/controllers/cards_controller.rb
</span><span style="color:#0c4a6e;">class CardsController &lt; ApplicationController
</span><span style="color:#0c4a6e;">  def create
</span><span style="color:#0c4a6e;">    @card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  def update
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">    Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  private
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  def board_column_id = params[:new_list_id]
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  def new_position = params[:new_position]
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/controllers/columns_controller.rb
</span><span style="color:#0c4a6e;">class ColumnsController &lt; ApplicationController
</span><span style="color:#0c4a6e;">  def create
</span><span style="color:#0c4a6e;">    @column = Board::Column.create board_id: params[:board_id], name: "New column"
</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  def update
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">    Board::Column.find(params[:id]).update position: new_position
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  private
</span><span style="color:#475569;">-
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  def new_position = params[:new_position]
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>Yay for removing code! 🎉</p>
<p>The routes needed a bit of Rails “magic” to wire this up cleanly. I used a routing concern to add the <code>reposition</code> endpoint to both cards and columns:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># config/routes.rb
</span><span style="color:#0c4a6e;">Rails.application.routes.draw do
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  resources :columns, only: %w[create update]
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  resources :cards, only: %w[create update]
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  concern :reposition do
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    collection do
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">      patch :reposition, controller: "reposition", action: "update", defaults: { resource_name: "board/#{@scope.frame.dig(:controller)}" }
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    end
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  end
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  resources :columns, only: %w[create update], concerns: :reposition
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  resources :cards, only: %w[create update], concerns: :reposition
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  root to: "pages#show"
</span><span style="color:#0c4a6e;">end
</span></code></pre>
<p>This routing concern is a neat trick. It adds a <code>reposition</code> route to any resource that includes it. The <code>defaults</code> hash automatically sets the <code>resource_name</code> parameter based on the controller name, so <code>cards</code> becomes <code>board/cards</code> and <code>columns</code> becomes <code>board/columns</code>. This way the <code>RepositionController</code> knows which model to work with. The <code>@scope.frame.dig(:controller)</code> is some more Rails “magic” that gives you the current controller name in the routing context.</p>
<p>Now for the Stimulus controller updates. I imported the MultiDrag plugin from SortableJS and handle multiple selected items:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/javascript/controllers/sortable_controller.js
</span><span style="color:#0c4a6e;">import { Controller } from "@hotwired/stimulus"
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">import Sortable from "sortablejs"
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">import { Sortable, MultiDrag } from "sortablejs"
</span><span style="color:#0c4a6e;">import { patch } from "@rails/request.js"
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">Sortable.mount(new MultiDrag())
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">export default class extends Controller {
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  static values = { groupName: String, endpoint: String };
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  static values = { groupName: String, endpoint: String, multiDraggable: Boolean };
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  connect() {
</span><span style="color:#0c4a6e;">    Sortable.create(this.element,
</span><span style="color:#0c4a6e;">      {
</span><span style="color:#0c4a6e;">        group: this.groupNameValue,
</span><span style="color:#0c4a6e;">        draggable: "[draggable]",
</span><span style="color:#0c4a6e;">        animation: 250,
</span><span style="color:#0c4a6e;">        easing: "cubic-bezier(1, 0, 0, 1)",
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        ghostClass: "opacity-50",
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        selectedClass: "selected",
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">        onEnd: this.#updatePosition.bind(this)
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        multiDrag: this.multiDraggableValue,
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        multiDragKey: "shift",
</span><span style="color:#475569;">+
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        onEnd: (event) =&gt; this.#updatePosition(event)
</span><span style="color:#0c4a6e;">      }
</span><span style="color:#0c4a6e;">    )
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  // private
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  async #updatePosition(event) {
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    const items = event.items?.length &gt; 0 ? event.items : [event.item]
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">    const ids = items.map(item =&gt; item.dataset.sortableIdValue)
</span><span style="color:#475569;">+
</span><span style="color:#0c4a6e;">    await patch(
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">      this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue),
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">      { body: JSON.stringify({ new_list_id: event.to.dataset.sortableListIdValue, new_position: event.newIndex + 1 }) }
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">      this.endpointValue,
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">      {
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        body: JSON.stringify({
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">          ids: ids,
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">          board_column_id: event.to.dataset.sortableListIdValue,
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">          new_position: event.newIndex + 1
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">        })
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">      }
</span><span style="color:#0c4a6e;">    )
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>The <code>multiDragKey</code> is set to <code>"shift"</code>, so you hold Shift and click cards to select multiple. The <code>selectedClass</code> gets applied to selected items, which I styled with a simple ring:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-style:italic;color:#475569;">/*</span><span style="font-style:italic;color:#64748b;"> app/assets/tailwind/application.css </span><span style="font-style:italic;color:#475569;">*/
</span><span style="font-weight:bold;color:#475569;">@</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">"</span><span style="color:#0369a1;">tailwindcss</span><span style="color:#475569;">";
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">@layer utilities </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  .selected {
</span><span style="color:#0c4a6e;">    @apply ring ring-gray-400;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>This small utility class is needed, as Sortable does not support adding multiple classes.</p>
<p>Finally, I updated the views to use the new endpoints and enable multi-drag for cards:</p>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/boards/_board.html.erb
</span><span style="color:#0c4a6e;">&lt;h1 class="mx-4 mt-2 text-lg font-bold text-gray-800"&gt;
</span><span style="color:#0c4a6e;">  &lt;%= board.name %&gt;
</span><span style="color:#0c4a6e;">&lt;/h1&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">&lt;div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]"&gt;
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(board) %&gt;" data-controller="sortable" data-sortable-endpoint-value="&lt;%= column_path(id: "__ID__") %&gt;" class="flex gap-x-8"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(board) %&gt;" data-controller="sortable" data-sortable-endpoint-value="&lt;%= reposition_columns_path %&gt;" class="flex gap-x-8"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= render board.columns %&gt;
</span><span style="color:#0c4a6e;">  &lt;/ul&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  &lt;%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %&gt;
</span><span style="color:#0c4a6e;">&lt;/div&gt;
</span></code></pre>
<pre lang="diff" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;"># app/views/boards/_column.html.erb
</span><span style="color:#0c4a6e;">&lt;li draggable data-sortable-id-value="&lt;%= column.id %&gt;"class="relative w-80 h-[var(--column-height)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto"&gt;
</span><span style="color:#0c4a6e;">  &lt;h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= column.name %&gt;
</span><span style="color:#0c4a6e;">  &lt;/h2&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">-</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(column) %&gt;" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="&lt;%= column.id %&gt;" data-sortable-endpoint-value="&lt;%= card_path(id: "__ID__") %&gt;" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip"&gt;
</span><span style="color:#475569;">+</span><span style="color:#0c4a6e;">  &lt;ul id="&lt;%= dom_id(column) %&gt;" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="&lt;%= column.id %&gt;" data-sortable-multi-draggable-value="true" data-sortable-endpoint-value="&lt;%= reposition_cards_path %&gt;" class="flex flex-col gap-y-2 px-4 pt-0.25 pb-4 overflow-x-clip"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= render column.cards %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    &lt;li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block"&gt;
</span><span style="color:#0c4a6e;">      &lt;p class="text-sm font-normal text-gray-600"&gt;No cards here…&lt;/p&gt;
</span><span style="color:#0c4a6e;">    &lt;/li&gt;
</span><span style="color:#0c4a6e;">  &lt;/ul&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  &lt;div class="sticky bottom-0 px-4 py-2 bg-gray-100/90"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %&gt;
</span><span style="color:#0c4a6e;">  &lt;/div&gt;
</span><span style="color:#0c4a6e;">&lt;/li&gt;
</span></code></pre>
<p>Now you can hold Shift, click multiple cards and drag them all at once to a new column. Pretty slick, right?! 😎</p>
<hr>
<p>And that’s it! You now have a fully functional Kanban board where you can add new cards and columns on the fly and even move multiple cards at once. The routing concern pattern keeps things DRY and the generic <code>RepositionController</code> means you could easily extend this to other sortable resources in your app.</p>
]]></content>
  </entry>
  <entry>
    <title>Announcing Attractive.js, a new JavaScript-free JavaScript library</title>
    <link href="https://railsdesigner.com/announcing-attractive-js/?ref=rss" rel="alternate" type="text/html"/>
    <published>2025-10-23T07:30:00Z</published>
    <updated>2025-10-23T07:30:00Z</updated>
    <id>https://railsdesigner.com/announcing-attractive-js/?ref=rss</id>
    <author>
      <name>Rails Designer</name>
      <email>support@railsdesigner.com</email>
    </author>
    <content type="html" xml:base="https://railsdesigner.com/announcing-attractive-js/?ref=rss"><![CDATA[<p>After last week’s <a href="/introducing-perron/">introduction of Perron</a>, I am now announcing another little “OSS present”: <strong>a JavaScript-free JavaScript library</strong>. 🎁 Say what?</p>
<p>👉 If you want to <a href="https://github.com/Rails-Designer/attractive.js">check out the repo and star ⭐ it</a>, that would make my day! 😊</p>
<p>Attractive.js lets you add interactivity to your site using only HTML attributes (hence the name <strong>attr</strong>ibute <strong>active</strong>). No JavaScript code required. Just add data-action<code>and, optionally,</code>data-target` attributes to your elements and… done! Something like this:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">addClass#bg-black</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">#door</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  Paint it black
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">door</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  Paint me black
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Or if you want to toggle a CSS class, you write: <code>data-action="toggleClass#bg-black"</code>. Or toggle multiple CSS classes: <code>data-action="toggleClass#bg-black,text-white"</code>.</p>
<p>Other actions include <code>addAttribute</code>, <code>form#submit</code> and <code>copy#my-api-key</code>. Attractive, right? 😅</p>
<p>I designed Attractive.js to be a little sister of Stimulus, hence the similar <code>data-*</code> attributes API. It also draws inspiration from Alpine, but Attractive.js aims to be way more minimal than both libraries (I guess it also looks like htmx, but I only learned about its syntax when writing this article).</p>
<p>It will never be a replacement for either library (or any advanced JS UI library). For static websites, it’s likely all the interactivity layer you’ll need. In early-stage Rails applications, it could serve as a lighter alternative to Stimulus. Together with modern CSS, it provides just enough interactivity to quickly ship both static sites and Turbo-powered Rails apps.</p>
<h2>
<a href="#attractivejs-actions" aria-hidden="true" class="anchor" id="attractivejs-actions"></a>Attractive.js’ actions</h2>
<p>Attractive.js has currently a humble set of “actions”. These actions are chosen to give you most bang for your buck. So you can move quicker and launch faster by writing absolutely no JavaScript! These action groups are currently supported.</p>
<ul>
<li>attributes</li>
<li>classes</li>
<li>clipboard</li>
<li>data attributes</li>
<li>dialog</li>
<li>form</li>
<li>intersection</li>
<li>reload</li>
<li>scrollTo</li>
</ul>
<p>Each action is by default applied to the current element (i.e. the element that has the <code>data-action</code> attribute applied). You choose another target (or targets), by setting the <code>data-target</code>.</p>
<p>Actions are named logically and can optionally have a value, as the classes actions showed. Or with the (data) attributes actions it could be written like this:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">data-action="toggleAttribute#disabled=disabled"
</span></code></pre>
<p>This would add a <code>disabled=disabled</code> attribute and value to the defined target. Or how about this one that will copy <code>my-api-key</code> to the clipboard?</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">data-action="copy#my-api-key"
</span></code></pre>
<p>Attractive, you say? I think so too! 😎</p>
<h3>
<a href="#events" aria-hidden="true" class="anchor" id="events"></a>Events</h3>
<p>Just like Stimulus’ event handling, Attractive.js uses the same defaults. <code>click</code> for <code>button</code> and <code>a</code>, <code>submit</code> for <code>form</code>, <code>change</code> for <code>select</code>, etc.</p>
<p>You can also override these or add event listeners to elements that normally would not have them, like <code>div</code>, <code>section</code> or <code>h*</code>. Like this <code>&lt;section data-action="mouseover-&gt;addAttribute#open data-target="details"&gt;</code>.</p>
<h2>
<a href="#works-with-stimulus" aria-hidden="true" class="anchor" id="works-with-stimulus"></a>Works with Stimulus</h2>
<p>Attractive.js works perfect together Stimulus. So once you need more advanced JavaScript handling you can introduce Stimulus while letting Attractive.js handle the basic interactivity.</p>
<h2>
<a href="#code-example-for-inspiration" aria-hidden="true" class="anchor" id="code-example-for-inspiration"></a>Code example for inspiration</h2>
<p>Here are some examples (including some live examples using CodePen) to give an idea what can be done with Attractive.js.</p>
<h3>
<a href="#submit-form-on-change" aria-hidden="true" class="anchor" id="submit-form-on-change"></a>Submit form on change</h3>
<p>You often have written a Stimulus controller to do this. Now you can add simply data attributes.</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">checklist</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">check_box </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">verify_naming</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">verify_naming</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Double check that I used the 'Attractive' pun enough times</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">check_box </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">update_readme</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">update_readme</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Update README to mention we're JavaScript-free*</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">check_box </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">add_asterisk</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">add_asterisk</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Add footnote: *technically still JavaScript</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">check_box </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">social_copy</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form#submit</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">social_copy</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Write the most attractive article ever</span><span style="color:#475569;">" %&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Or how about a typical country, followed by a region select?</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> turbo_frame_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">location</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">%&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with </span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">location</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">id</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">form</span><span style="color:#475569;">" </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">form</span><span style="color:#475569;">| %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">country</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Country</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0284c7;">select </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">country</span><span style="color:#475569;">, [["</span><span style="color:#0369a1;">Select a country…</span><span style="color:#475569;">", ""], "</span><span style="color:#0369a1;">France</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">Germany</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">Japan</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">Spain</span><span style="color:#475569;">"], {}, </span><span style="font-weight:bold;color:#075985;">data</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">action</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">reload</span><span style="color:#475569;">", </span><span style="font-weight:bold;color:#075985;">target</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">#location</span><span style="color:#475569;">" } %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">region</span><span style="color:#475569;">, "</span><span style="color:#0369a1;">Region</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form</span><span style="color:#475569;">.</span><span style="color:#0284c7;">select </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">region</span><span style="color:#475569;">, [["</span><span style="color:#0369a1;">Select a country first</span><span style="color:#475569;">", ""]], { </span><span style="font-weight:bold;color:#075985;">disabled</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">@</span><span style="color:#1e293b;">location</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">country</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">blank? </span><span style="color:#475569;">} %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Upon selecting the country, the turbo-frame will reload, thus removing the <code>disabled</code> attribute on the region select.</p>
<h3>
<a href="#toggle-password-visibility" aria-hidden="true" class="anchor" id="toggle-password-visibility"></a>Toggle password visibility</h3>
<p><a href="https://codepen.io/railsdesigner/pen/yyepBRj">Check this CodePen</a> to view how to toggle the visibility of a password field. Nice UX for sign up and registration screens. This uses <code>cycleAttribute#type=password,text</code> as the action.</p>
<h3>
<a href="#copy-code-to-your-clipboard" aria-hidden="true" class="anchor" id="copy-code-to-your-clipboard"></a>Copy code to your clipboard</h3>
<p>This example, using <code>data-action="copy"</code> and <code>data-target="#pre"</code>, shows <a href="https://codepen.io/railsdesigner/pen/OPMxPVK">how to add a copy to clipboard button for code</a> (view it on Codepen). It shows a different icon after copied and then reverts back to original icon.</p>
<p>This example was taken from the <a href="https://perron.railsdesigner.com/docs/resources/">Perron docs</a>.</p>
<h3>
<a href="#slideshow-with-images" aria-hidden="true" class="anchor" id="slideshow-with-images"></a>Slideshow (with images)</h3>
<p>How about a slidehow? A component you see often on (marketing) sites. <a href="https://codepen.io/railsdesigner/pen/emJGJEG">Check out this CodePen</a> on how to do it (it uses <code>data-action="addDataAttribute#slideshowNumber=n"</code>).</p>
<h3>
<a href="#select-all-checkboxes" aria-hidden="true" class="anchor" id="select-all-checkboxes"></a>Select all checkboxes</h3>
<p>Need to (de)select all checkboxes? This one uses a nested target along with <code>toggleAttribute#checked=checked</code>, e.g. <code>#form [type=checkbox]</code>. <a href="https://codepen.io/railsdesigner/pen/wBMrWGE">See the CodePen</a>.</p>
<h3>
<a href="#tabs-content" aria-hidden="true" class="anchor" id="tabs-content"></a>Tabs content</h3>
<p>Who doesnt have tabs somewhere in their app or site? <a href="https://codepen.io/railsdesigner/pen/emJGmee">Check out this CodePen how you can tackle this</a>. Using <code>addDataAttribute#visible=tab-n</code> as the action.</p>
<h3>
<a href="#work-with-native-dialog-element" aria-hidden="true" class="anchor" id="work-with-native-dialog-element"></a>Work with native dialog element</h3>
<p>The native <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog">HTML dialog element</a> gives a a11y-ready-dialog for free. You need no extra JS when using Attractive.js, just add <code>dialog#{open,openModal}</code>. <a href="https://codepen.io/railsdesigner/pen/PwZJqqb">Check out this CodePen for a live example</a>.</p>
<p>(Transition upon show done using CSS’ <code>@starting-style</code> property)</p>
<h2>
<a href="#the-story-behind-attractivejs" aria-hidden="true" class="anchor" id="the-story-behind-attractivejs"></a>The story behind Attractive.js</h2>
<p>I had the idea for Attractive.js for a long time, but it was never more than a little, fragile idea. This little idea became clearer after building some of my products and sites and <a href="https://railsdesigner.com/rails-ui-consultancy/">working with</a> <a href="https://railsdesigner.com/saas/">multiple clients</a> and <del>needing</del>trying to explain the verbosity of some Stimulus controllers for seemingly little interactivity. Take a toggle class Stimulus controller:</p>
<pre lang="js" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Controller </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@hotwired/stimulus</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default class extends </span><span style="color:#0c4a6e;">Controller </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">targets </span><span style="color:#0c4a6e;">= ["element"]
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">static </span><span style="color:#0284c7;">values </span><span style="color:#0c4a6e;">= { classes: String </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">toggle</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">elementTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">classList</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toggle</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">classesValue</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>And then wire up the HTML:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">div </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">classes</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">classes#toggle</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-classes-value</span><span style="color:#475569;">="</span><span style="color:#0369a1;">bg-black</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-classes-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">button</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Paint it black</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#0369a1;">data-classes-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">element</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    Paint me black
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>Compare that to the Attractive.js example:</p>
<pre lang="html" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">button </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">addClass#bg-black</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">#door</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  Paint it black
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">button</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">p </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">door</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">  Paint me black
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">p</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>You see there is a gap between common functionality for websites and (early) stage Rails apps powered by Turbo.</p>
<p>So in March of 2025 I sat down and wrote a small proof of concept (it is still in the original repo’s commit history 🤫).</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">Attractive </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">initialize</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">('</span><span style="color:#0369a1;">click</span><span style="color:#475569;">', </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">bind</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">));
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">('</span><span style="color:#0369a1;">change</span><span style="color:#475569;">', </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">bind</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">));
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">this</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">event</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">element </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">target</span><span style="color:#475569;">.</span><span style="color:#1e293b;">closest</span><span style="color:#475569;">('</span><span style="color:#0369a1;">[data-action]</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">element</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">actionValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">targetSelector </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0284c7;">getAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-target</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">actionValue</span><span style="color:#475569;">.</span><span style="color:#1e293b;">startsWith</span><span style="color:#475569;">('</span><span style="color:#0369a1;">toggleClass#</span><span style="color:#475569;">')) {
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">className </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">actionValue</span><span style="color:#475569;">.</span><span style="color:#1e293b;">split</span><span style="color:#475569;">('</span><span style="color:#0369a1;">#</span><span style="color:#475569;">')[</span><span style="font-weight:bold;color:#d97706;">1</span><span style="color:#475569;">];
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toggleClass</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#1e293b;">className</span><span style="color:#475569;">, </span><span style="color:#1e293b;">targetSelector</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">},
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">toggleClass</span><span style="color:#475569;">(</span><span style="color:#1e293b;">element</span><span style="color:#475569;">, </span><span style="color:#1e293b;">className</span><span style="color:#475569;">, </span><span style="color:#1e293b;">targetSelector</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">targets </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">targetSelector
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">? </span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelectorAll</span><span style="color:#475569;">(</span><span style="color:#1e293b;">targetSelector</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">[</span><span style="color:#1e293b;">element</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">parentElement</span><span style="color:#475569;">];
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    Array</span><span style="color:#475569;">.</span><span style="color:#1e293b;">from</span><span style="color:#475569;">(</span><span style="color:#1e293b;">targets</span><span style="color:#475569;">).</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">target </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">target</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">classList</span><span style="color:#475569;">.</span><span style="color:#1e293b;">toggle</span><span style="color:#475569;">(</span><span style="color:#1e293b;">className</span><span style="color:#475569;">);
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">});
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">};
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">typeof </span><span style="color:#0c4a6e;">document </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#475569;">'</span><span style="color:#0369a1;">undefined</span><span style="color:#475569;">') {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">document</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">readyState </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#475569;">'</span><span style="color:#0369a1;">loading</span><span style="color:#475569;">') {
</span><span style="color:#0c4a6e;">    document</span><span style="color:#475569;">.</span><span style="color:#1e293b;">addEventListener</span><span style="color:#475569;">('</span><span style="color:#0369a1;">DOMContentLoaded</span><span style="color:#475569;">', () </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">Attractive</span><span style="color:#475569;">.</span><span style="color:#1e293b;">initialize</span><span style="color:#475569;">());
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">Attractive</span><span style="color:#475569;">.</span><span style="color:#1e293b;">initialize</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">typeof </span><span style="color:#0c4a6e;">window </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#475569;">'</span><span style="color:#0369a1;">undefined</span><span style="color:#475569;">') {
</span><span style="color:#0c4a6e;">  window</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">Attractive </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">Attractive</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">export default </span><span style="color:#1e293b;">Attractive</span><span style="color:#475569;">;
</span></code></pre>
<p>Yes, pretty gnarly. But it showed what I had in mind (<em>JS-free JS library</em>) was possible. It took quite a few more iterations: adding a clean adapter-like system for various classes, using Intersection Observer and so on. Above code is in some form still in the current code base. 😊</p>
<hr>
<p>It is early days, but I am already using Attractive.js on the docs site for <a href="https://perron.railsdesigner.com/">Perron</a> (see sidebar on smaller screens, copy to clipboard for code and ToC also on smaller screens) and <a href="https://attractivejs.railsdesigner.com/">Attractive.js</a>.</p>
<p>Would love if you could give the Attractive.js repo that lovely <a href="https://github.com/Rails-Designer/attractive.js">star on GitHub</a> and give it a try in your next project. ⭐</p>
]]></content>
  </entry>
</feed>
