<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://perron.railsdesigner.com/" version="1.1.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-07-09T07:30:00Z</updated>

  
  <author>
    <name>Rails Designer</name>
    <email>support@railsdesigner.com</email>
  </author>

  
  <entry>
    <id>https://railsdesigner.com/feature-guard/?ref=rss</id>
    <title>Permission UI the Rails way</title>
    <link href="https://railsdesigner.com/feature-guard/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-07-09T07:30:00Z</published>
    <updated>2026-07-09T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/feature-guard/?ref=rss"><![CDATA[<p>I’ve <a href="/hire/">built many a SaaS</a>. And most have pricing tiers: the higher the plans the more <em>widgets</em> you can create or features are allowd. Typically there are two ways to approach this:</p>
<ul>
<li>hide the feature if the customer does not have access;</li>
<li>show all features, to all customers, but guard them with some, optional, upsell.</li>
</ul>
<p>I have succesfully implementated the latter approach with a helper that quacks like Rails itself.</p>
<p>The helper lets you wrap content and decide what happens when access is denied. Hide it, redirect to a link, render content or render a partial. It’s small but flexible. This is how it looks (added a toggle allowed/denied states):</p>
<p><img src="https://railsdesigner.com/images/posts/allowed-helper.gif" alt=""></p>
<p>As always <a href="https://github.com/rails-designer-repos/conditional_link_to">view on GitHub</a> for a ready-to-copy code.</p>
<p>The API is elegant. Let’s look at some examples:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> allowed? Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">premium? </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;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Create project</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> projects_path </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>When <code>Current.user.premium?</code> is true, the link renders. When false, nothing shows. That’s the default behavior.</p>
<p>But you can do more. Redirect to a link instead:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> allowed? Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">premium?</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">redirect_to</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> upgrade_path </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;"> link_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Advanced analytics</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> analytics_path </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 content becomes a clickable link pointing to your upgrade page.</p>
<p>Show plain text as a fallback:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> allowed? Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">premium?</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">render</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">plain</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Upgrade to unlock this.</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;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete project</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> project_path</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:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>Or render HTML:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> allowed? Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">premium?</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">render</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">html</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Upgrade to the &lt;strong&gt;Pro plan&lt;/strong&gt;.</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;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Delete project</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> project_path</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:#475569;">&lt;% </span><span style="font-weight:bold;color:#dc2626;">end </span><span style="color:#475569;">%&gt;
</span></code></pre>
<p>And render a partial for complex fallbacks:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> allowed? Current</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">premium?</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">render</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">upsell</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;"> button_to </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Advanced settings</span><span style="color:#475569;">",</span><span style="color:#0c4a6e;"> settings_path </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>
<h2>
<a href="#the-helper" aria-hidden="true" class="anchor" id="the-helper"></a>The helper</h2>
<p>Here’s the full helper in <code>app/helpers/allowed_helper.rb</code>:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">AllowedHelper
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">allowed?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">condition</span><span style="color:#475569;">, </span><span style="color:#1e293b;">options </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;">return yield if</span><span style="color:#0c4a6e;"> condition
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> options</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">redirect_to</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">      content </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> capture </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#dc2626;">yield </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      link_to strip_tags</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">content</span><span style="color:#475569;">),</span><span style="color:#0c4a6e;"> options</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">redirect_to</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">elsif</span><span style="color:#0c4a6e;"> options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">key?</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">render</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      render_options </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> options</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">render</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:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0284c7;">is_a?</span><span style="color:#475569;">(</span><span style="color:#0284c7;">Hash</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;</span><span style="color:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">key?</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">html</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">        render_options</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">html</span><span style="color:#475569;">].</span><span style="color:#0c4a6e;">html_safe
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">elsif</span><span style="color:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0284c7;">is_a?</span><span style="color:#475569;">(</span><span style="color:#0284c7;">Hash</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;</span><span style="color:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">key?</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">plain</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">        render_options</span><span style="color:#475569;">[</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">plain</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">else
</span><span style="color:#0c4a6e;">        render</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:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0284c7;">to_s </span><span style="color:#475569;">}) </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0284c7;">is_a?</span><span style="color:#475569;">(</span><span style="color:#0284c7;">String</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">||</span><span style="color:#0c4a6e;"> render_options</span><span style="color:#475569;">.</span><span style="color:#0284c7;">is_a?</span><span style="color:#475569;">(</span><span style="color:#1e293b;">Symbol</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>The logic is straightforward. If the condition is true, render the block. If false, check your options and handle it accordingly.</p>
<p>When you pass <code>redirect_to</code>, it captures the block content, strips HTML tags and wraps it in a link. When you pass <code>render</code>, it supports three formats: plain text, raw HTML or a partial name as a string or symbol.</p>
<p>This helper did, ~10 years ago, not start like this. Over the years, after working on many dozens Rails-based SaaS apps, my aim became to make helpers “quack” more like Rails methods. You’re familiar with <code>render plain:</code>, <code>render html:</code> and partial rendering. This helper just extends that familiar syntax to conditional rendering.</p>
<p>I like it. What do you think of this?</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/changelog-with-perron/?ref=rss</id>
    <title>Build a changelog widget (with Perron)</title>
    <link href="https://railsdesigner.com/changelog-with-perron/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-07-02T07:30:00Z</published>
    <updated>2026-07-02T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/changelog-with-perron/?ref=rss"><![CDATA[<p>Changelog widgets, like those from Headway and Beamer, are still a popular way to tell your users about updates to your product. What if you could build one yourself?</p>
<p><a href="https://perron.railsdesigner.com">Perron</a>, the Rails-based SSG, has a library of production-ready custom elements. One of them, the <a href="https://perron.railsdesigner.com/library/embed/">Embed Content component</a> is perfect for this exact use case. It fetches JSON data, renders it as a list, tracks read state and handles all the UI interactions. You just provide the data.</p>
<p>The result will look like this:</p>
<p><img src="https://railsdesigner.com/images/posts/changelog-widget.gif" alt="GIF of changelog widget in action"></p>
<p>Pretty smooth, right?</p>
<p>Most changelog services lock you into their platform. You pay per seat, deal with their branding and accept their feature set. If you want something simpler or more tailored to your product, you’re stuck building it from scratch.</p>
<p>But here’s the thing: a changelog widget isn’t complicated. It needs to fetch some data, display it nicely, track which items users have read and maybe show a badge with unread counts. That’s it. The Embed Content component is a custom element that fetches JSON from any endpoint, renders items with a template you control, tracks unread state based on a <code>last-read-at</code> timestamp, shows an unread badge automatically and handles open/close toggle states.</p>
<p>You drop it into your HTML, point it at your changelog endpoint and it works. You write the changelog entries in your Perron site (where they are also published. Win-win! 🏆).</p>
<h2>
<a href="#getting-started" aria-hidden="true" class="anchor" id="getting-started"></a>Getting Started</h2>
<p>First, add the Embed Content component to your project. The <a href="https://perron.railsdesigner.com/library/embed/">setup instructions</a> are straightforward: run the template in your app and the custom element is ready to use.</p>
<p>Next, you’ll need a JSON feed. Perron makes this easy: check out the <a href="https://perron.railsdesigner.com/docs/feeds/#custom-templates">Perron docs</a> to see how to create a custom template that outputs your changelog items as JSON.</p>
<p>Once your feed is live, drop the Embed Content component into your view:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">embed-content </span><span style="color:#0369a1;">id</span><span style="color:#475569;">="</span><span style="color:#0369a1;">changelog-widget</span><span style="color:#475569;">" </span><span style="color:#0369a1;">src</span><span style="color:#475569;">="</span><span style="color:#0369a1;">https://example.com/changelog.json</span><span style="color:#475569;">" </span><span style="color:#0369a1;">limit</span><span style="color:#475569;">="</span><span style="color:#0369a1;">5</span><span style="color:#475569;">" </span><span style="color:#0369a1;">last-read-at</span><span style="color:#475569;">="</span><span style="color:#0369a1;">2026-03-20T00:00:00Z</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;">aria-label</span><span style="color:#475569;">="</span><span style="color:#0369a1;">Toggle changelog</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;">svg </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;">" </span><span style="color:#0369a1;">width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">20</span><span style="color:#475569;">" </span><span style="color:#0369a1;">height</span><span style="color:#475569;">="</span><span style="color:#0369a1;">20</span><span style="color:#475569;">" </span><span style="color:#0369a1;">viewBox</span><span style="color:#475569;">="</span><span style="color:#0369a1;">0 0 24 24</span><span style="color:#475569;">" </span><span style="color:#0369a1;">fill</span><span style="color:#475569;">="</span><span style="color:#0369a1;">none</span><span style="color:#475569;">" </span><span style="color:#0369a1;">stroke</span><span style="color:#475569;">="</span><span style="color:#0369a1;">currentColor</span><span style="color:#475569;">" </span><span style="color:#0369a1;">stroke-width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">2</span><span style="color:#475569;">" </span><span style="color:#0369a1;">stroke-linecap</span><span style="color:#475569;">="</span><span style="color:#0369a1;">round</span><span style="color:#475569;">" </span><span style="color:#0369a1;">stroke-linejoin</span><span style="color:#475569;">="</span><span style="color:#0369a1;">round</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;">path </span><span style="color:#0369a1;">d</span><span style="color:#475569;">="</span><span style="color:#0369a1;">M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9</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;">path </span><span style="color:#0369a1;">d</span><span style="color:#475569;">="</span><span style="color:#0369a1;">M10.3 21a1.94 1.94 0 0 0 3.4 0</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;">svg</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;">span </span><span style="color:#0369a1;">badge</span><span style="color:#475569;">&gt;&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:#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;">div </span><span style="color:#0369a1;">panel</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;">header</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;">What's new</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:#0c4a6e;">    </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">ul </span><span style="color:#0369a1;">items</span><span style="color:#475569;">&gt;&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;">div</span><span style="color:#475569;">&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">embed-content</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The component reads the <code>src</code> attribute, fetches your JSON and renders each item. The <code>limit</code> attribute controls how many items show. The <code>last-read-at</code> attribute tracks which items are new: anything published after that timestamp gets marked as unread and counted in the badge.</p>
<p>Your feed doesn’t have to come from Perron. The component just needs JSON with <code>title</code>, <code>body</code>, <code>url</code> and <code>published_at</code> fields. You could use a custom Rails endpoint, a headless CMS or any JSON API. The pattern is the same.</p>
<p>The component uses semantic HTML and CSS custom properties, so styling is straightforward. You control the colors, spacing and layout. This pattern works for any feed-like feature: product announcements, status updates, notification centers or feature releases. The <a href="https://perron.railsdesigner.com/library/embed/">Embed Content component</a> handles the hard parts. You focus on your content. ✨</p>
<p>Check out the <a href="https://github.com/rails-designer-repos/changelog-widget">example repository</a> for the complete example including CSS.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/deploy-with-beam-up/?ref=rss</id>
    <title>Beam Up: CLI to deploy static sites</title>
    <link href="https://railsdesigner.com/deploy-with-beam-up/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-06-25T07:30:00Z</published>
    <updated>2026-06-25T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/deploy-with-beam-up/?ref=rss"><![CDATA[<p>Beam Up is a command-line tool I wanted for some time. Its goal is to make deployments of static sites across various providers simpler from your machine: run one command: <code>beam_up ./output/</code>. ❤️</p>
<p>If you feel generous, head over to <a href="https://github.com/Rails-Designer/beam_up">the GitHub repo</a> and give it a star! 🌟</p>
<h2>
<a href="#installation-and-configuration" aria-hidden="true" class="anchor" id="installation-and-configuration"></a>Installation and configuration</h2>
<p>To install Beam Up, run:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">gem</span><span style="color:#0c4a6e;"> install beam_up
</span></code></pre>
<p>Then initialize a configuration file for your chosen provider with:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">beam_up</span><span style="color:#0c4a6e;"> init </span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> to start an interactive process, or:
</span><span style="font-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> beam_up init hetzner
</span></code></pre>
<p>This creates a <code>.beam_up.yml</code> file:</p>
<pre lang="yaml" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">provider</span><span style="color:#475569;">: </span><span style="color:#0369a1;">netlify
</span><span style="font-weight:bold;color:#dc2626;">netlify</span><span style="color:#475569;">:
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">api_token</span><span style="color:#475569;">: </span><span style="color:#0369a1;">your_token_here
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">project_id</span><span style="color:#475569;">: </span><span style="color:#0369a1;">your_project_id
</span></code></pre>
<h2>
<a href="#ready-to-beam-" aria-hidden="true" class="anchor" id="ready-to-beam-"></a>Ready to beam ⚡</h2>
<p>Now you can deploy your static site using the super fun <code>beam_up</code> command:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">beam_up</span><span style="color:#0c4a6e;"> ./output
</span></code></pre>
<p>Beam Up supports multiple providers including AWS S3, <a href="https://sealstatic.com">Seal Static</a>, Hetzner, Netlify and Statichost. Oh, and it even works with SFTP (remember those days?!).</p>
<h2>
<a href="#usage-from-ruby" aria-hidden="true" class="anchor" id="usage-from-ruby"></a>Usage from Ruby</h2>
<p>Beam Up can also be used within Ruby scripts:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">require </span><span style="color:#475569;">"</span><span style="color:#0369a1;">beam_up</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">BeamUp</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">deploy! </span><span style="color:#475569;">"</span><span style="color:#0369a1;">./output</span><span style="color:#475569;">"
</span></code></pre>
<h2>
<a href="#integration-with-perron" aria-hidden="true" class="anchor" id="integration-with-perron"></a>Integration with Perron</h2>
<p>While Beam Up is designed to work with any SSG (Jekyll, Middleman or Bridgetown), I built it for <a href="https://github.com/Rails-Designer/perron">Perron</a>. With the latest version, you can now simply run <code>bin/rails perron:deploy</code>, it will build your site (<code>bin/rails perron:build</code>) and deploy it: your site will be live in no time. Without any config, it will default to deploy to <a href="https://sealstatic.com">Seal Static</a> (where an account is created for you on first deploy). Cool, right? 😎</p>
<hr>
<p>Beam Up is a small, new OSS project made with ❤️ from Rails Designer HQ. If you like it, do <a href="https://github.com/Rails-Designer/beam_up">star it on GitHub</a> and give it a try on your (next) SSG project.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/before-after-images-stimulus/?ref=rss</id>
    <title>Build a reusable before/after image slider with Stimulus</title>
    <link href="https://railsdesigner.com/before-after-images-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-06-18T07:30:00Z</published>
    <updated>2026-06-18T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/before-after-images-stimulus/?ref=rss"><![CDATA[<p>Before and after (image) sliders are great for product showcases, before/after transformations, renovation projects or photo editing results. They’re engaging, interactive, and honestly just fun to use.</p>
<p>Ever thought of building one from scratch? Not complicated at all! In this article, I want to show how to build a simple, reusable Stimulus controller that does just all that. This is how the end result will look like:</p>
<p><img src="https://railsdesigner.com/images/posts/before-after-preview.jpg" alt=""></p>
<p>As always, <a href="https://github.com/rails-designer-repos/before-after-image">the code can be found on GitHub</a>.</p>
<h2>
<a href="#it-is-simple-really" aria-hidden="true" class="anchor" id="it-is-simple-really"></a>It is simple, really</h2>
<p>The beauty of this controller is its simplicity. You need three things:</p>
<ul>
<li>a wrapper element;</li>
<li>two images (before and after), and;</li>
<li>a slider.</li>
</ul>
<p>That’s it. Here’s it in 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;">compare</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;">img </span><span style="color:#0369a1;">data-compare-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">before</span><span style="color:#475569;">" </span><span style="color:#0369a1;">src</span><span style="color:#475569;">="</span><span style="color:#0369a1;">before.jpg</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;">img </span><span style="color:#0369a1;">data-compare-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">after</span><span style="color:#475569;">" </span><span style="color:#0369a1;">src</span><span style="color:#475569;">="</span><span style="color:#0369a1;">after.jpg</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;">data-compare-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">slider</span><span style="color:#475569;">"&gt;&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;">div</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The two images are positioned absolutely and the slider sits on top as a draggable handle. The “after” image gets clipped dynamically as you drag.</p>
<p>Here’s that interesting part. It uses <code>clip-path</code> to reveal or hide the after image:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">set</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">position</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:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">positionValue </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">value
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">sliderTarget</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;">value </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">100</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;">afterTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">clipPath </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`</span><span style="color:#0369a1;">inset(0 0 0 </span><span style="color:#475569;">${</span><span style="color:#1e293b;">value </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">100</span><span style="color:#475569;">}</span><span style="color:#0369a1;">%)</span><span style="color:#475569;">`
</span><span style="color:#475569;">}
</span></code></pre>
<p>When you drag the slider, it calculates the position relative to the container’s width, then updates both the slider’s left position and the clip-path of the after image. Most of that is done with:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">pointerDown </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:#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;">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;">sliderTarget</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;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">preventDefault</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:#1e293b;">addEventListener</span><span style="color:#475569;">("</span><span style="color:#0369a1;">pointermove</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;">pointerMove</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;">pointerup</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;">pointerUp</span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span></code></pre>
<h2>
<a href="#stimulus-controllers-are-meant-to-be-reusable" aria-hidden="true" class="anchor" id="stimulus-controllers-are-meant-to-be-reusable"></a>Stimulus controllers are meant to be reusable</h2>
<p>This controller is a wrapper element plus two images. That’s it. Use cases then are limitless (they generally work great on marketing blogs as well!):</p>
<ul>
<li>
<strong>product photography</strong>; show different angles or colors of the same product</li>
<li>
<strong>photo editing results</strong>; before/after with filters, adjustments, or retouching</li>
<li>
<strong>UI redesigns</strong>; old interface vs. new interface</li>
</ul>
<hr>
<p>You could (should) extend this in several directions. Add keyboard support for accessibility. Include labels (“Before” / “After”) that move with the slider. Add touch support with haptic feedback on mobile.</p>
<p>It is the kind of controller you copy and paste into your projects. Just as it should be.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/quiz-stimulus/?ref=rss</id>
    <title>Building a quiz with Stimulus</title>
    <link href="https://railsdesigner.com/quiz-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-06-11T07:30:00Z</published>
    <updated>2026-06-11T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/quiz-stimulus/?ref=rss"><![CDATA[<p>Quizzes are a fun! Well… I do think they are. Always up to learn new things. So how can you create one in with your favorite frameworks? In this article, I want to show how you can build a quiz witg Stimulus. It can be a good starting point for learn about a new customer in your SaaS or as a smart, little marketing tool (keep readers engaged/on your page). 💡</p>
<p>The quiz loads questions from a Rails endpoint, tracks answers in real-time, calculates results and submits them to your server. As always, <a href="https://github.com/rails-designer-repos/stimulus-quiz">the code can be found on GitHub</a>.</p>
<p><img src="https://railsdesigner.com/images/posts/binary-quiz.jpg" alt=""></p>
<p>This will be the result. Time to dust off that good ol’ computer science knowledge… 🤓</p>
<h2>
<a href="#building-the-data-class" aria-hidden="true" class="anchor" id="building-the-data-class"></a>Building the data class</h2>
<p>Here’s the <code>QuizData</code> class that handles the quiz logic. For this example it lives inside the Stimulus controller, but you can organise it however you want.</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;">QuizData </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">constructor</span><span style="color:#475569;">(</span><span style="color:#1e293b;">questions</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;">questions </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">questions
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answers </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><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">answer</span><span style="color:#475569;">(</span><span style="color:#1e293b;">questionIndex</span><span style="color:#475569;">, </span><span style="color:#1e293b;">selectedOption</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;">answers</span><span style="color:#475569;">[</span><span style="color:#1e293b;">questionIndex</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">selectedOption
</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;">getAnswer</span><span style="color:#475569;">(</span><span style="color:#1e293b;">questionIndex</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;">answers</span><span style="color:#475569;">[</span><span style="color:#1e293b;">questionIndex</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;">correctCount</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;">questions</span><span style="color:#475569;">.</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">((</span><span style="color:#1e293b;">question</span><span style="color:#475569;">, </span><span style="color:#1e293b;">questionIndex</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;">const </span><span style="color:#1e293b;">answer </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answers</span><span style="color:#475569;">[</span><span style="color:#1e293b;">questionIndex</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">answer </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="font-weight:bold;color:#075985;">undefined </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">answer </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#1e293b;">question</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">correct
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}).</span><span style="color:#0c4a6e;">length
</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;">result</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;">questions</span><span style="color:#475569;">.</span><span style="color:#1e293b;">reduce</span><span style="color:#475569;">((</span><span style="color:#1e293b;">accumulate</span><span style="color:#475569;">, </span><span style="color:#1e293b;">question</span><span style="color:#475569;">, </span><span style="color:#1e293b;">questionIndex</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;">const </span><span style="color:#1e293b;">answer </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answers</span><span style="color:#475569;">[</span><span style="color:#1e293b;">questionIndex</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;">accumulate </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#475569;">((</span><span style="color:#1e293b;">answer </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#1e293b;">question</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">correct</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="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">questions</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;">1 </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">questionIndex</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:#475569;">)
</span><span style="color:#0c4a6e;">    </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;">get </span><span style="color:#0284c7;">totalQuestions</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;">questions</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length
</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;">get </span><span style="color:#0284c7;">isComplete</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#0c4a6e;">Object</span><span style="color:#475569;">.</span><span style="color:#1e293b;">keys</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answers</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">=== </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">totalQuestions
</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:#0284c7;">default</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">QuizData</span><span style="color:#475569;">([
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">        question</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">What is </span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">Hi</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> in binary?</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">        options</span><span style="color:#475569;">: ["</span><span style="color:#0369a1;">01001000 01101001</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">01000111 01101001</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">01001000 01110011</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">01000001 01101001</span><span style="color:#475569;">"],
</span><span style="color:#0c4a6e;">        correct</span><span style="color:#475569;">: </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-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> more questions…
</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></code></pre>
<p>This class provides methods to record answers, checks if all questions are answered and calculate the score and encode results. The <code>result()</code> method is a little bit clever, it encodes which questions were answered correctly as a binary number, making it easy to store and analyze results server-side.</p>
<p>The <code>static default()</code> method provides quiz data if you do not have a remote endpoint so you can use it also outside of a Rails app (in a <a href="https://perron.railsdesigner.com/">static site</a>, for example).</p>
<h2>
<a href="#the-rails-endpoint" aria-hidden="true" class="anchor" id="the-rails-endpoint"></a>The Rails endpoint</h2>
<p>Your quiz questions need to come from somewhere. Create a <code>QuizzesController</code> with an <code>index</code> action that returns questions as JSON:</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;">QuizzesController </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;">    render </span><span style="font-weight:bold;color:#075985;">json</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;">questions</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">[
</span><span style="color:#0c4a6e;">        </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">question</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">What is </span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;">Hi</span><span style="font-weight:bold;color:#075985;">\"</span><span style="color:#0369a1;"> in binary?</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">options</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">["</span><span style="color:#0369a1;">01001000 01101001</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">01000111 01101001</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="font-weight:bold;color:#075985;">correct</span><span style="font-weight:bold;color:#475569;">: </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-style:italic;color:#475569;">#</span><span style="font-style:italic;color:#64748b;"> etc…
</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="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;">create
</span><span style="color:#0c4a6e;">    Rails</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">logger</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">info </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Quiz submission: </span><span style="color:#475569;">#{</span><span style="color:#0369a1;">params</span><span style="color:#475569;">.</span><span style="color:#0369a1;">permit!</span><span style="color:#475569;">.</span><span style="color:#0284c7;">to_h</span><span style="color:#475569;">}"
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    head </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">ok
</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>The <code>index</code> action returns an array of questions with their options and the index of the correct answer. The <code>create</code> action receives the submitted answers, you can process them here however you want (send along, store in DB, etc.).</p>
<h2>
<a href="#the-stimulus-controller" aria-hidden="true" class="anchor" id="the-stimulus-controller"></a>The Stimulus controller</h2>
<p>Now the Stimulus where it all happens 🛏️✨. This controller fetches questions from your endpoint, renders them and handles user interactions. It uses the <code>@rails/request.js</code> library to make clean HTTP requests.</p>
<p>When the controller connects, it fetches the questions:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">async 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;">quiz </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;">getQuestions</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;">render</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">async</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">getQuestions</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;">hasQuestionsEndpointValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">try </span><span style="color:#475569;">{
</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 get</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">questionsEndpointValue</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">responseKind</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">json</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;">response</span><span style="font-weight:bold;color:#0369a1;">?</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">questions</span><span style="font-weight:bold;color:#0369a1;">?</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;">0</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">QuizData</span><span style="color:#475569;">(</span><span style="color:#1e293b;">response</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">questions</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">catch </span><span style="color:#475569;">(</span><span style="color:#1e293b;">error</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      console</span><span style="color:#475569;">.</span><span style="color:#0284c7;">warn</span><span style="color:#475569;">("</span><span style="color:#0369a1;">Failed to load questions from endpoint, using defaults:</span><span style="color:#475569;">", </span><span style="color:#1e293b;">error</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;">QuizData</span><span style="color:#475569;">.</span><span style="color:#1e293b;">default</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>This check <code>this.hasQuestionsEndpointValue</code> is added so I can use this controller also outside of a Rails app.</p>
<p>When a user selects an answer, the controller records it and enables the submit button (when all questions are answered):</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">selectAnswer</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;">optionsContainer </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-quiz-question-index]</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">questionIndex </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseInt</span><span style="color:#475569;">(</span><span style="color:#1e293b;">optionsContainer</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">quizQuestionIndex</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">10</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">selectedOption </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">parseInt</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;">value</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#d97706;">10</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;">quiz</span><span style="color:#475569;">.</span><span style="color:#1e293b;">answer</span><span style="color:#475569;">(</span><span style="color:#1e293b;">questionIndex</span><span style="color:#475569;">, </span><span style="color:#1e293b;">selectedOption</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;">submitTarget</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;">quiz</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">isComplete
</span><span style="color:#475569;">}
</span></code></pre>
<p>When the user submits the quiz, the controller sends the answers and result to your Rails endpoint. For details on how <code>@rails/request.js</code> works and how to make GET and POST requests from a Stimulus controller, <a href="https://railsdesigner.com/request-from-stimulus-controller/">check out this article</a> 😊. What is happening is that you’re sending structured data (answers and a calculated result) to your server:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">async submit</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">payload </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    answers</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">quiz</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answers</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    result</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">quiz</span><span style="color:#475569;">.</span><span style="color:#1e293b;">result</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;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">hasAnswersEndpointValue</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">await </span><span style="color:#1e293b;">post</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">answersEndpointValue</span><span style="color:#475569;">, {
</span><span style="color:#0c4a6e;">      body</span><span style="color:#475569;">: </span><span style="color:#1e293b;">JSON</span><span style="color:#475569;">.</span><span style="color:#1e293b;">stringify</span><span style="color:#475569;">(</span><span style="color:#1e293b;">payload</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;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">showResults</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>After submission, the controller displays the results and hides the quiz:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">showResults</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">correct </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">quiz</span><span style="color:#475569;">.</span><span style="color:#1e293b;">correctCount</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">total </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">quiz</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">totalQuestions
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">fieldsets </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:#1e293b;">querySelectorAll</span><span style="color:#475569;">("</span><span style="color:#0369a1;">fieldset</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">fieldsets</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">fieldset </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">fieldset</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;">resultsTarget</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;div&gt;</span><span style="color:#475569;">${</span><span style="color:#1e293b;">correct</span><span style="color:#475569;">}</span><span style="color:#0369a1;"> out of </span><span style="color:#475569;">${</span><span style="color:#1e293b;">total</span><span style="color:#475569;">}</span><span style="color:#0369a1;"> correct&lt;/div&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;">submitTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">hidden </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>The HTML view is minimal:</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;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">quiz</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-quiz-questions-endpoint-value</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> quizzes_path </span><span style="color:#475569;">%&gt;" </span><span style="color:#0369a1;">data-quiz-answers-endpoint-value</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> quizzes_path </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;">output </span><span style="color:#0369a1;">data-quiz-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">results</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">output</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-quiz-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">submit</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">quiz#submit</span><span style="color:#475569;">" </span><span style="color:#0369a1;">disabled</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Submit Quiz</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;">article</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The controller renders each question as a fieldset with radio buttons. The styling uses modern CSS (oklch color space; see <a href="https://railsdesigner.com/building-customizable-tailwind-themes/">this article</a>, CSS Grid) to create a clean, accessible interface. The CSS isn’t included here, but it handles the layout, hover states and checked states for the radio buttons.</p>
<hr>
<p>This quiz is a good starting point. You could add pagination to show only a few questions at a time, add a countdown timer that auto-submits when time runs out, send detailed results to an analytics service to track which questions trip up your users, allow retakes so users can compare scores over time or fetch different question sets based on difficulty level.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/meet-mata/?ref=rss</id>
    <title>Meet Mata: live reload for Rack Apps</title>
    <link href="https://railsdesigner.com/meet-mata/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-06-04T07:30:00Z</published>
    <updated>2026-06-04T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/meet-mata/?ref=rss"><![CDATA[<p>I build quite a few sites. For my own products or <a href="https://railsdesigner.com/hire/">I help others</a>. Since I released <a href="https://perron.railsdesigner.com">Perron</a> that is my go-to static site generator now. But it was missing one feature for a speedy development cycle: live reload. Not just a page refresh, but more like hot reloading.</p>
<p>Most live reload solutions either require ActionCable (overkill when using Perron) or just do full-page reloads (annoying/too minimal). I wanted something in between.</p>
<p>Meet <strong>Mata</strong>: a lightweight live reload solution for Rack apps. It uses <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Server-Sent Events (SSE)</a> and <a href="https://github.com/bigskysoftware/idiomorph">idiomorph</a>.</p>
<p>You can install Mata simply by running <code>bundle add mata --group=development</code>. Then configure the middleware with <code>watch</code> and <code>skip</code> paths. That’s it. No JavaScript bundling. No ActionCable. No WebSockets. Just smart live reloading! ✨</p>
<p>But Mata isn’t just for Perron. It works with any Rack-based framework, like Rails: Sinatra, Hanami or Roda. 🚀 See the GitHub README for set up examples.</p>
<p>Look, mom, no hands:</p>
<p><img src="https://railsdesigner.com/images/posts/mmata-preview.gif" alt=""></p>
<p>So why the name? It’s <a href="https://github.com/Rails-Designer/mata#who-is-mata">named after the legendary double agent: Mata Hari</a>. 🕵️💃🤓</p>
<p>(oh, I learned while in Indonesia, in Bahasa Indonesia, <em>mata</em> means eye. A fun coincidence, for something that watches your files. 😅)</p>
<hr>
<p>Already using Mata (you do if you use <a href="https://github.com/Rails-Designer/perron">Perron</a>!)? <a href="https://github.com/Rails-Designer/mata">Give Mata a star on GitHub</a>. ⭐❤️</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/building-customizable-tailwind-themes/?ref=rss</id>
    <title>Building user-customizable themes with Tailwind CSS</title>
    <link href="https://railsdesigner.com/building-customizable-tailwind-themes/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-05-28T07:30:00Z</published>
    <updated>2026-05-28T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/building-customizable-tailwind-themes/?ref=rss"><![CDATA[<p>A little while ago <a href="https://railsdesigner.com/hire/">I was asked to to build</a> a “white-label” feature into an existing app. Their customers can now customize the app to match their brand without touching code.</p>
<p>It will look a little something like this:</p>
<p><img src="https://railsdesigner.com/images/colors-slider.gif" alt=""></p>
<p>This article walks through how I build a custom theme system using Tailwind CSS and the OKLCH color space. The basics for this feature I extracted and is available on <a href="https://github.com/rails-designer-repos/custom-theme">GitHub</a>.</p>
<p>The approach I took generates an entire color palette from a single value, instead of managing eleven different color stops manually. Sounds complicated? It really is quite simple. Let me show you.</p>
<p>Tailwind allows defining custom colors using the <code>@theme</code> directive (these can then be used normally, e.g. <code>text-brand-500</code> and <code>bg-brand-50/60</code>). Rather than hardcoding hex values, use CSS variables that change at runtime.</p>
<p>Here is the setup in <code>app/assets/tailwind/application.css</code>:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><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;">@theme </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-50</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.99 0.01 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-100</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.98 0.02 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-200</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.94 0.04 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-300</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.86 0.08 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-400</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.74 0.14 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-500</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.60 0.18 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-600</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.52 0.16 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-700</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.44 0.14 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-800</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.36 0.12 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-900</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.26 0.08 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">--</span><span style="color:#0c4a6e;">color-brand-950</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">oklch(</span><span style="font-weight:bold;color:#d97706;">0.16 0.04 </span><span style="color:#0284c7;">var</span><span style="color:#475569;">(--</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p>Notice the same <code>--color-value</code> variable? By changing this one variable, all eleven shades update automatically. The lightness and chroma values stay consistent (for a harmonious palette). 🌈</p>
<p>Use these colors in your templates like any other Tailwind color:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">nav </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">bg-brand-50 border-brand-100</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;">text-brand-600</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Custom Theme</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:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">nav</span><span style="color:#475569;">&gt;
</span></code></pre>
<h2>
<a href="#oklch" aria-hidden="true" class="anchor" id="oklch"></a>OKLCH?</h2>
<p>But wait… OKLCH? What is that? OKLCH is a modern color space that separates color into three components: lightness, chroma and hue. This makes it a perfect choice for generating color palettes (Tailwind CSS changed to OKLCH internally as well since v4, iirc).</p>
<p>Take the <code>brand-500</code> color from the example above:</p>
<pre style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">oklch(0.60 0.18 var(--color-value))
</span></code></pre>
<p>So:</p>
<ul>
<li>
<code>0.60</code> is the lightness. A value of 0 is black, 1 is white. This color is medium brightness.</li>
<li>
<code>0.18</code> is the chroma (or color saturation). Higher values are more vibrant.</li>
<li>
<code>var(--color-value)</code> is the hue angle in degrees (0-360). This is what changes when you update the theme and defined initially in the style-element.</li>
</ul>
<p>The beauty of OKLCH is that lightness and chroma remain constant across the palette. Only the hue changes. This means your brand color can shift from red to blue to green while maintaining the same visual weight and saturation at each shade level.</p>
<h2>
<a href="#setting-up-the-html" aria-hidden="true" class="anchor" id="setting-up-the-html"></a>Setting up the HTML</h2>
<p>In your layout, add a style tag with the default theme value:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">head</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;">style </span><span style="color:#0369a1;">data-theme</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">root </span><span style="color:#475569;">{ --</span><span style="color:#0c4a6e;">color-value</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">60</span><span style="color:#475569;">; }
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">style</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> stylesheet_link_tag </span><span style="color:#475569;">"</span><span style="color:#0369a1;">tailwind</span><span style="color:#475569;">", "</span><span style="color:#0369a1;">data-turbo-track</span><span style="color:#475569;">"</span><span style="font-weight:bold;color:#0369a1;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">reload</span><span style="color:#475569;">" %&gt;
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> javascript_importmap_tags </span><span style="color:#475569;">%&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">head</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The default value of 60 is a sorta yellow-green hue. You can change it however you want.</p>
<h2>
<a href="#wiring-up-the-theme-controller" aria-hidden="true" class="anchor" id="wiring-up-the-theme-controller"></a>Wiring up the theme controller</h2>
<p>I just added this Stimulus controller for this demo (the client’s app worked a bit different).</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;">targets </span><span style="color:#0c4a6e;">= ["value"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">initialize</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;">style </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;">style[data-theme]</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;">update</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;">value </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:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</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;">:root { --color-value: </span><span style="color:#475569;">${</span><span style="color:#1e293b;">value</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;">valueTarget</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>The controller finds the existing style tag marked with <code>data-theme</code> and updates its content. This triggers a cascade: the CSS variable changes, all the brand colors are recalculated and the entire app recolors instantly. ✨</p>
<h2>
<a href="#the-demo-interface" aria-hidden="true" class="anchor" id="the-demo-interface"></a>The demo interface</h2>
<p>The example includes a simple slider to demonstrate the feature:</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;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">flex items-center gap-4 px-6 py-4 border-b border-brand-200</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">theme</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;">text-sm text-brand-700</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Theme</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:#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;">range</span><span style="color:#475569;">" </span><span style="color:#0369a1;">min</span><span style="color:#475569;">="</span><span style="color:#0369a1;">0</span><span style="color:#475569;">" </span><span style="color:#0369a1;">max</span><span style="color:#475569;">="</span><span style="color:#0369a1;">360</span><span style="color:#475569;">" </span><span style="color:#0369a1;">value</span><span style="color:#475569;">="</span><span style="color:#0369a1;">60</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">theme#update</span><span style="color:#475569;">" </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">w-32 accent-brand-600</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-theme-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">value</span><span style="color:#475569;">" </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">text-sm text-brand-500</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">60</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:#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:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">mt-2 p-6 text-brand-900</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Move the slider above to change the theme color.</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:#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;">flex gap-4 p-6</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;">li </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">size-16 rounded-lg bg-brand-50 border border-brand-200</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</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;">li </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">size-16 rounded-lg bg-brand-100 border border-brand-200</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</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;">li </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">size-16 rounded-lg bg-brand-200 border border-brand-200</span><span style="color:#475569;">"&gt;&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</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;"> … all the way to brand-950 </span><span style="font-style:italic;color:#475569;">--&gt;
</span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">ul</span><span style="color:#475569;">&gt;
</span></code></pre>
<h2>
<a href="#user-selected-themes" aria-hidden="true" class="anchor" id="user-selected-themes"></a>User-selected themes</h2>
<p>While this approach was built for a white-label app, it works equally well for user-customizable themes.</p>
<p>The technique is flexible. You could extend it to store multiple theme variables: primary color, secondary color, accent color. Each would be a separate CSS variable that users can customize independently.</p>
<p>Pretty cool, right?</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/actionmailbox-fuik/?ref=rss</id>
    <title>Inbound email in Rails apps with Fuik</title>
    <link href="https://railsdesigner.com/actionmailbox-fuik/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-05-21T07:30:00Z</published>
    <updated>2026-05-21T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/actionmailbox-fuik/?ref=rss"><![CDATA[<p><a href="https://railsdesigner.com/introducing-fuik/">Fuik</a> is a Rails engine for handling webhooks that I launched recently. But it can also be used to process inbound emails! 💡</p>
<p>When working with inbound email in Rails using Action Mailbox, there’s a key requirement: managing EML files and blob storage. Once an email arrives at your domain, your email provider captures it and sends the data to you via webhook. You then extract what you need and process (or store) it. Action Mailbox requires: ActiveStorage ánd ActionMailer.</p>
<p>But what if you don’t have a need for ActiveStorage (including its cloud storage setup) or ActionMailer (because you use <a href="https://github.com/Rails-Designer/courrier/">Courrier</a>).</p>
<p>Most transactional email providers send their inbound emails as webhooks: Postmark, SendGrid, Mailgun and Mailpace. They all POST the email data to a webhook URL you configure.</p>
<p>Fuik is great at capturing just that: it captures those incoming webhooks, stores them in the database and calls your event class to process them. You see all your webhooks in at <code>/webhooks</code>. You can inspect and copy payloads, pull path accessors and generally debug what’s happening.</p>
<p>It is simple to get started:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">bundle</span><span style="color:#0c4a6e;"> add fuik
</span><span style="color:#1e293b;">bin/rails</span><span style="color:#0c4a6e;"> generate fuik:install
</span><span style="color:#1e293b;">bin/rails</span><span style="color:#0c4a6e;"> db:migrate
</span></code></pre>
<p>That’s it. Your app now has <code>/webhooks</code> endpoints ready to receive any webhooks. Now to process inbound emails, let’s look at Postmark as an example (though every provider that sends inbound emails as webhooks wil work).</p>
<p>Postmark lets you receive inbound emails by pointing a MX record to their servers and then configure a webhook URL. When an email arrives, the data is POSTed to you. Let’s see how to process it in your app with Fuik.</p>
<p>Generate the class:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">bin/rails</span><span style="color:#0c4a6e;"> generate fuik:provider postmark inbound_email
</span></code></pre>
<p>This creates three files.</p>
<p>Now open <code>app/webhooks/postmark/inbound_email.rb</code>. Postmark sends <code>"To"</code>, <code>"From"</code>, <code>"Subject"</code>, <code>"TextBody"</code>, and <code>"HtmlBody"</code>. Route emails based on the recipient address:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">Postmark
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">InboundEmail </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Base
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">process!
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">return if</span><span style="color:#0c4a6e;"> to_email</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">blank?
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">case</span><span style="color:#0c4a6e;"> to_email
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">when </span><span style="color:#475569;">/</span><span style="color:#0369a1;">replies-.*@</span><span style="color:#475569;">/
</span><span style="color:#0c4a6e;">        message </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Message</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;">email_address</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> to_email</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">        message</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">replies</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create!</span><span style="color:#475569;">(
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">content</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> text_content</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">from</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> from_email
</span><span style="color:#0c4a6e;">        </span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">when </span><span style="color:#475569;">/</span><span style="color:#0369a1;">support@</span><span style="color:#475569;">/
</span><span style="color:#0c4a6e;">        SupportTicket</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create!</span><span style="color:#475569;">(
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">from</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> from_email</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">subject</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> subject</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#075985;">body</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> text_content
</span><span style="color:#0c4a6e;">        </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:#475569;">@</span><span style="color:#1e293b;">webhook_event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">processed!
</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;">to_email</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">payload</span><span style="color:#0c4a6e;">["</span><span style="color:#1e293b;">To</span><span style="color:#0c4a6e;">"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">from_email</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">payload</span><span style="color:#0c4a6e;">["</span><span style="color:#1e293b;">From</span><span style="color:#0c4a6e;">"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">subject</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">payload</span><span style="color:#0c4a6e;">["</span><span style="color:#1e293b;">Subject</span><span style="color:#0c4a6e;">"]
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">text_content</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">payload</span><span style="color:#0c4a6e;">["</span><span style="color:#1e293b;">TextBody</span><span style="color:#0c4a6e;">"]
</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>Postmark doesn’t send an event type in the payload (all inbound webhooks are inbound emails), so you set it statically. Use Fuik’s config (<code>app/webhooks/postmark/config.yml</code>) to extract it:</p>
<pre lang="yaml" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">event_type</span><span style="color:#475569;">:
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">source</span><span style="color:#475569;">: </span><span style="color:#0369a1;">static
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">value</span><span style="color:#475569;">: </span><span style="color:#0369a1;">inbound_email
</span></code></pre>
<p>Point your Postmark inbound webhook to <code>https://yourdomain.com/webhooks/postmark</code> in their dashboard, and emails start flowing in.</p>
<p>In the repo shared, I also added an example for Sendgrid to show another example how to do this.</p>
<h2>
<a href="#any-provider-with-webhooks" aria-hidden="true" class="anchor" id="any-provider-with-webhooks"></a>Any provider with webhooks</h2>
<p>Postmark and SendGrid are just examples. Mailgun, Brevo, Mailpace and others all send structured JSON via webhooks. The pattern is the same. Generate a provider, map the payload fields to your private methods, add your business logic and mark the event as processed.</p>
<p>Check out the <a href="https://github.com/Rails-Designer/fuik">example repository</a> with both Postmark and SendGrid set up.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/thoughts-notes/?ref=rss</id>
    <title>Thoughts, notes and what I read</title>
    <link href="https://railsdesigner.com/thoughts-notes/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-05-18T07:30:00Z</published>
    <updated>2026-05-18T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/thoughts-notes/?ref=rss"><![CDATA[<p>I’ve never used “social” media. Instead, I read a lot on various topics: Rails, web/tech and beyond. Next to the weekly practical article a week on Rails, Hotwire, CSS, <a href="https://railsdesigner.com/saas/">SaaS</a> and design I published, I have, since March, started sharing some thoughts and notes. They’re not publicly listed, but accessible via <a href="https://railsdesigner.com/feed/notes.xml">Atom feed only</a>.</p>
<p>I hope to see more platform-independent Twitter-style feeds from others too. Web standards like RSS/Atom are great and don’t lock you into a platform or company. Just linear posts, no ragebaits or shitposting from others (and if so, unsubscribe).</p>
<p>With <a href="https://perron.railsdesigner.com">Perron</a>’s <a href="https://perron.railsdesigner.com/docs/feeds/#custom-templates">customisable feeds feature</a>, this is super easy to set up. I also created a tiny Raycast extension that makes posting super simple and quick too.</p>
<p>If you have a similar kind of feed, do share it below! 👇</p>
<p>Mine can be found at with <a href="https://railsdesigner.com/feed/notes.xml">here</a>.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/image-upload-element/?ref=rss</id>
    <title>Reusable drag-and-drop image preview in Rails</title>
    <link href="https://railsdesigner.com/image-upload-element/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-05-14T07:30:00Z</published>
    <updated>2026-05-14T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/image-upload-element/?ref=rss"><![CDATA[<p>Custom elements have <a href="https://railsdesigner.com.custom-element-inline-edit/">been</a> <a href="https://railsdesigner.com/custom-elements/">covered</a> here berfore. 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> are custom elements. They are just HTML tags with JavaScript behavior attached.</p>
<p>This article walks through building a <strong>drag-and-drop image upload custom element</strong> that works great in Rails forms. Starting with a simple avatar and ending with a reusable component that handles both inline and external forms. The code is, as usual, <a href="https://github.com/rails-designer-repos/image-upload-element">available on GitHub</a>.</p>
<p>So first, why not use a regular file input or a Stimulus controller? The answer is that custom elements are perfect for self-contained components. They work anywhere in your HTML without needing to wire up data attributes or controller targets. Drop the tag in your view and it works. No configuration. No boilerplate.</p>
<p>With Stimulus you write:</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;">image-upload</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;">input </span><span style="color:#0369a1;">type</span><span style="color:#475569;">="</span><span style="color:#0369a1;">file</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-image-upload-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">input</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;image-upload#remove</span><span style="color:#475569;">"&gt;</span><span style="color:#0c4a6e;">Remove</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>With the custom element I want to explore today, you write:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">image-upload </span><span style="color:#0369a1;">name</span><span style="color:#475569;">="</span><span style="color:#0369a1;">user[avatar]</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-preview-image-url</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="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-remove-image</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Remove</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;">image-upload</span><span style="color:#475569;">&gt;
</span></code></pre>
<p>The component creates its own file input. It finds the form automatically. It handles drag-and-drop, preview rendering and removal. All wrapped in a clean, semantic HTML tag.</p>
<p>Lets get started, by creating the custom element in <code>app/javascript/components/image-uploads.js</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;">ImageUpload </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;">  #img = null;
</span><span style="color:#0c4a6e;">  #input = null;
</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;">img </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;">image</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;">input </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;">fileInput</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;">load</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;">img</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;">dragAndDropListeners</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;">removeButtonListener</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;">clickToSelectListener</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;">image-upload</span><span style="color:#475569;">', </span><span style="color:#1e293b;">ImageUpload</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 the image element, creates the file input, loads any existing image and wires up all the event listeners.</p>
<p>The component creates an <code>&lt;img&gt;</code> tag if one does not already exist (only if the <code>data-preview-image-url</code> attribute is present):</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">image</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:#1e293b;">hasAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-preview-image-url</span><span style="color:#475569;">')) </span><span style="font-weight:bold;color:#dc2626;">return </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="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">img </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;">img</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:#0284c7;">insertBefore</span><span style="color:#475569;">(</span><span style="color:#1e293b;">img</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">firstChild</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;">img</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span></code></pre>
<p>This means you do not need to add an <code>&lt;img&gt;</code> tag to your HTML. If you want to show an existing image, just add the <code>data-preview-image-url</code> attribute with the image URL.</p>
<p>Now it gets interesting. The component creates a hidden file input and <strong>appends it to the form</strong>, not to the component itself:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fileInput</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="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;">name</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    console</span><span style="color:#475569;">.</span><span style="color:#0284c7;">error</span><span style="color:#475569;">('</span><span style="color:#0369a1;">image-upload requires a "name" attribute</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#075985;">null</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;">const </span><span style="color:#1e293b;">input </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;">input</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">input</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;">file</span><span style="color:#475569;">';
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">input</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">name </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">name</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">input</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">accept </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">'</span><span style="color:#0369a1;">image/*</span><span style="color:#475569;">';
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">input</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">style</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">display </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">'</span><span style="color:#0369a1;">none</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;">formId </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;">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;">form </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">formId </span><span style="font-weight:bold;color:#0369a1;">? </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;">formId</span><span style="color:#475569;">) </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;">form</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;">form</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">form</span><span style="color:#475569;">.</span><span style="color:#0284c7;">appendChild</span><span style="color:#475569;">(</span><span style="color:#1e293b;">input</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:#0284c7;">appendChild</span><span style="color:#475569;">(</span><span style="color:#1e293b;">input</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;">input</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;">event </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;">fileSelected</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:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="color:#1e293b;">input</span><span style="color:#475569;">;
</span><span style="color:#475569;">}
</span></code></pre>
<p>This is crucial. The file input must be part of the form for Rails to receive it in the params. The component looks for a form in two ways: if a <code>form</code> attribute is present, it uses that ID to find the form. Otherwise, it finds the first form in the DOM. Use it inside a form (it finds the form automatically) or outside a form (pass the form ID via the <code>form</code> attribute).</p>
<p>When a file is selected (either via click or drag-and-drop), the component renders a preview:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">fileSelected</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;">imageFile </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;">files</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:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">imageFile</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;">process</span><span style="color:#475569;">(</span><span style="color:#1e293b;">imageFile</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;">process</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file</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;">img</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;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file</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;">render</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">reader </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">FileReader</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  reader</span><span style="color:#475569;">.</span><span style="color:#0284c7;">onload </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:#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;">img</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">src </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;">result</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;">reader</span><span style="color:#475569;">.</span><span style="color:#1e293b;">readAsDataURL</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>FileReader</code> converts the file to a data URL which is displayed in the image element. The user sees the preview immediately.</p>
<p>The component listens for drag-and-drop events and adds a visual indicator:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">dragAndDropListeners</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;">dragover</span><span style="color:#475569;">', </span><span style="color:#1e293b;">event </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;">dragOver</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:#1e293b;">addEventListener</span><span style="color:#475569;">('</span><span style="color:#0369a1;">dragleave</span><span style="color:#475569;">', </span><span style="color:#1e293b;">event </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;">dragLeave</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:#1e293b;">addEventListener</span><span style="color:#475569;">('</span><span style="color:#0369a1;">drop</span><span style="color:#475569;">', </span><span style="color:#1e293b;">event </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;">drop</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">));
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">dragOver </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:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">preventDefault</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </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="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">setAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-drag-active</span><span style="color:#475569;">', '');
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">dragLeave </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:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">preventDefault</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </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="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">removeAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-drag-active</span><span style="color:#475569;">');
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">#</span><span style="color:#0284c7;">drop </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:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#1e293b;">preventDefault</span><span style="color:#475569;">();
</span><span style="color:#0c4a6e;">  </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="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0284c7;">removeAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">data-drag-active</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;">files </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataTransfer</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">files</span><span style="color:#475569;">;
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">imageFile </span><span style="font-weight:bold;color:#0369a1;">= </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;">files</span><span style="color:#475569;">).</span><span style="color:#1e293b;">find</span><span style="color:#475569;">(</span><span style="color:#1e293b;">file </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">file</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">type</span><span style="color:#475569;">.</span><span style="color:#1e293b;">startsWith</span><span style="color:#475569;">('</span><span style="color:#0369a1;">image/</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;">imageFile</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;">process</span><span style="color:#475569;">(</span><span style="color:#1e293b;">imageFile</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p>When the user drags a file over the component, it sets a <code>data-drag-active</code> attribute. Style this with CSS to show visual feedback like a blue border or background color.</p>
<p>The component finds a button with <code>data-remove-image</code> and wires it up:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">removeButtonListener</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">removeButton </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;">[data-remove-image]</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;">removeButton</span><span style="color:#475569;">) </span><span style="color:#1e293b;">removeButton</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;">remove</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;">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;">input</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value </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="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;">img</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;">img</span><span style="color:#475569;">.</span><span style="color:#0284c7;">removeAttribute</span><span style="color:#475569;">('</span><span style="color:#0369a1;">src</span><span style="color:#475569;">');
</span><span style="color:#475569;">}
</span></code></pre>
<p>When clicked, it clears the file input and removes the image preview. For <code>has_one_attached</code> in Rails, submitting an empty file input removes the attachment.</p>
<h2>
<a href="#using-the-component" aria-hidden="true" class="anchor" id="using-the-component"></a>Using the component</h2>
<p>Here is the avatar example inside a form:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> form_with</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> user</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="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;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">avatar</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">style</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">display: block</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;">image-upload </span><span style="color:#0369a1;">name</span><span style="color:#475569;">="</span><span style="color:#0369a1;">user[avatar]</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-preview-image-url</span><span style="color:#475569;">="&lt;%= </span><span style="color:#0284c7;">url_for</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">user</span><span style="color:#475569;">.</span><span style="color:#0369a1;">avatar</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">@</span><span style="color:#1e293b;">user</span><span style="color:#475569;">.</span><span style="color:#0369a1;">avatar</span><span style="color:#475569;">.</span><span style="color:#0369a1;">attached? </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;">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-remove-image</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Remove</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;">image-upload</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;">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;">label </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">email_address</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">style</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">display: block</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;">email_address </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;">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="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>And here is the header example outside the form:</p>
<pre lang="erb" style="background-color:#f8fafc;"><code><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">image-upload </span><span style="color:#0369a1;">name</span><span style="color:#475569;">="</span><span style="color:#0369a1;">user[header]</span><span style="color:#475569;">" </span><span style="color:#0369a1;">form</span><span style="color:#475569;">="</span><span style="color:#0369a1;">user_form</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-preview-image-url</span><span style="color:#475569;">="&lt;%= </span><span style="color:#0284c7;">url_for</span><span style="color:#475569;">(@</span><span style="color:#1e293b;">user</span><span style="color:#475569;">.</span><span style="color:#0369a1;">header</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">if </span><span style="color:#475569;">@</span><span style="color:#1e293b;">user</span><span style="color:#475569;">.</span><span style="color:#0369a1;">header</span><span style="color:#475569;">.</span><span style="color:#0369a1;">attached? </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;">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-remove-image</span><span style="color:#475569;">&gt;</span><span style="color:#0c4a6e;">Remove</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;">image-upload</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="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">model</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> user</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;">user_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="font-style:italic;color:#475569;">&lt;!--</span><span style="font-style:italic;color:#64748b;"> form fields </span><span style="font-style:italic;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>form="user_form"</code> attribute associates the component with the form by ID. The file input gets appended to that form and submits with it.</p>
<p>Custom elements work anywhere in your HTML without needing to wire up data attributes or controller targets. I’ve been enjoying custom elements more and more and for really contained examples like this one, or for static sites <a href="https://perron.railsdesigner.com/">built with Perron</a> I think they are wonderful tool to have.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/extending-nested-forms-stimulus/?ref=rss</id>
    <title>Adding edit, delete and reposition for nested forms in Rails with Stimulus</title>
    <link href="https://railsdesigner.com/extending-nested-forms-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-05-07T07:30:00Z</published>
    <updated>2026-05-07T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/extending-nested-forms-stimulus/?ref=rss"><![CDATA[<p>In a <a href="https://railsdesigner.com/rails-nested-form-with-stimulus/">previous article</a>, I explored building nested forms with Stimulus. But what about when you need to edit existing questions, remove ones you no longer need or reorganize them? Let’s extend that foundation by adding: editing, deleting and repositioning questions using drag-and-drop.</p>
<p>This article builds directly on the previous setup, so make sure you have that in place before continuing (<a href="https://github.com/rails-designer-repos/stimulus-nested-forms">check out the repo for the full code base</a>). The reposition logic is inspired by this <a href="https://railsdesigner.com/kanban-rails-hotwire/">article to create a Kanban board</a>.</p>
<p>First, update the migration to include a unique index:</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;">AddPositionToQuestions </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;">    add_column </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">questions</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">position</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">integer</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:#0c4a6e;">    add_index </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">questions</span><span style="color:#475569;">, [</span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">survey_id</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">position</span><span style="color:#475569;">], </span><span style="font-weight:bold;color:#075985;">unique</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true
</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>I like the positioning gem for this, make sure to set it up correctly.</p>
<p>Editing questions is really just vanilla Rails stuff, update the SurveysController with <code>edit</code> and <code>update</code> actions. It will save the question’s content alongside the survey models (I recently <a href="https://railsdesigner.com/nested-forms-without-accepts-nested-attributes/">wrote how you can do this without using <code>accepts_nested_attributes_for</code></a>).</p>
<p>Now the more interesting part is the logic to reposition questions. Let’s go over the related parts. Add a RepositionController that stores the new position after drag-and-drop.</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;">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="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="font-weight:bold;color:#0369a1;">+ </span><span style="color:#0284c7;">index</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;">resources
</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="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">resource_class
</span><span style="color:#0c4a6e;">    request</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">path</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">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;">singularize</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">classify</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">constantize
</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>Next is to updates your routes to include the new action:</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="font-weight:bold;color:#0369a1;">+</span><span style="color:#0c4a6e;">  resources </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">questions</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;">destroy</span><span style="color:#475569;">] </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="font-weight:bold;color:#0369a1;">+</span><span style="color:#0c4a6e;">    collection </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="font-weight:bold;color:#0369a1;">+</span><span style="color:#0c4a6e;">      patch </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">reposition</span><span style="color:#475569;">, </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;">reposition</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;">update</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#0369a1;">+    </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#0369a1;">+  </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Update the nested-fields controller to support question removal.</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="color:#0284c7;">remove</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="font-weight:bold;color:#0369a1;">+    </span><span style="color:#1e293b;">const field </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-sortable-id-value]</span><span style="color:#475569;">");
</span><span style="font-weight:bold;color:#0369a1;">+    </span><span style="color:#1e293b;">const destroyInput </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">('</span><span style="color:#0369a1;">input[name*="_destroy"]</span><span style="color:#475569;">');
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#0369a1;">+    </span><span style="color:#1e293b;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">destroyInput</span><span style="color:#475569;">) {
</span><span style="font-weight:bold;color:#0369a1;">+      </span><span style="color:#1e293b;">destroyInput</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">value </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">1</span><span style="color:#475569;">";
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#0369a1;">+      </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">hidden </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="font-weight:bold;color:#075985;">true</span><span style="color:#475569;">;
</span><span style="font-weight:bold;color:#0369a1;">+    </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">else </span><span style="color:#475569;">{
</span><span style="font-weight:bold;color:#0369a1;">+      </span><span style="color:#1e293b;">field</span><span style="color:#475569;">.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">();
</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:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>The <code>remove()</code> method checks if there’s a <code>_destroy</code> hidden field (for existing records). If so, it sets the value to “1” and hides the field. For new records without this field, it simply removes the element from the DOM.</p>
<h2>
<a href="#sortable-stimulus-controller" aria-hidden="true" class="anchor" id="sortable-stimulus-controller"></a>Sortable Stimulus controller</h2>
<p>Create a new Stimulus controller (this is mostly copied verbatim from the Kanban board article I mentioned) for drag-and-drop functionality:</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/sortable_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="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">Sortable </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">sortablejs</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">patch </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@rails/request.js</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;">= { endpoint: String </span><span style="color:#475569;">};
</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;">Sortable</span><span style="color:#475569;">.</span><span style="color:#1e293b;">create</span><span style="color:#475569;">(</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;">      group</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">questions</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      animation</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">150</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">      easing</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">cubic-bezier(1, 0, 0, 1)</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      ghostClass</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">opacity-50</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      selectedClass</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">selected</span><span style="color:#475569;">",
</span><span style="color:#0c4a6e;">      </span><span style="color:#0284c7;">onEnd</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:#0284c7;">updatePosition</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="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">async</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">updatePosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    const items = event.items?.length &gt; </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#0c4a6e;"> ? event.items </span><span style="color:#475569;">: [</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">item</span><span style="color:#475569;">]</span><span style="color:#0c4a6e;">;
</span><span style="color:#0c4a6e;">    const ids = items.</span><span style="color:#0284c7;">map</span><span style="color:#475569;">(</span><span style="color:#1e293b;">item </span><span style="font-weight:bold;color:#0369a1;">=&gt; </span><span style="color:#1e293b;">item</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">sortableIdValue</span><span style="color:#475569;">)</span><span style="color:#0c4a6e;">;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    await </span><span style="color:#0284c7;">patch</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">endpointValue</span><span style="color:#475569;">, {
</span><span style="color:#0c4a6e;">      </span><span style="color:#1e293b;">body</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">JSON</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">stringify</span><span style="color:#0c4a6e;">({
</span><span style="color:#0c4a6e;">        </span><span style="color:#1e293b;">ids</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">ids</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">        </span><span style="color:#1e293b;">new_position</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">event</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">newIndex</span><span style="color:#0c4a6e;"> + 1
</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:#475569;">}
</span><span style="color:#0c4a6e;">}
</span></code></pre>
<p>This controller uses <code>SortableJS</code> and <code>@rails/request.js</code> to enable drag-and-drop.</p>
<h2>
<a href="#update-the-edit-view" aria-hidden="true" class="anchor" id="update-the-edit-view"></a>Update the edit view</h2>
<p>Create the edit view (<strong>app/views/surveys/edit.html.erb</strong>):</p>
<pre lang="html" style="background-color:#f8fafc;"><code><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;">Edit Survey</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;">&lt;%= form_with model: @survey, data: {controller: "nested-fields"} do |form| %&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;">    &lt;%= form.label :name %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    &lt;%= form.text_field :name %&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;">ul </span><span style="color:#0369a1;">data-nested-fields-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">fields</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-controller</span><span style="color:#475569;">="</span><span style="color:#0369a1;">sortable</span><span style="color:#475569;">" </span><span style="color:#0369a1;">data-sortable-endpoint-value</span><span style="color:#475569;">="</span><span style="color:#0369a1;">&lt;%= reposition_questions_path %&gt;</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    &lt;%= form.fields_for :questions do |question_form| %&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">li </span><span style="color:#0369a1;">data-sortable-id-value</span><span style="color:#475569;">="</span><span style="color:#0369a1;">&lt;%= question_form.object.id %&gt;</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">        &lt;%= question_form.hidden_field :_destroy %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        &lt;%= question_form.label :content, "Question" %&gt;
</span><span style="color:#0c4a6e;">        &lt;%= question_form.text_area :content %&gt;
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        &lt;%= button_tag "Remove", type: :button, data: { action: "nested-fields#remove" } %&gt;
</span><span style="color:#0c4a6e;">      </span><span style="color:#475569;">&lt;/</span><span style="font-weight:bold;color:#dc2626;">li</span><span style="color:#475569;">&gt;
</span><span style="color:#0c4a6e;">    &lt;% end %&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:#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="font-style:italic;color:#475569;">&lt;!--</span><span style="font-style:italic;color:#64748b;"> … </span><span style="font-style:italic;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;">&lt;% end %&gt;
</span></code></pre>
<p>Key points in this view:</p>
<ul>
<li>The <code>&lt;ul&gt;</code> has both <code>nested-fields</code> and <code>sortable</code> controllers</li>
<li>Each <code>&lt;li&gt;</code> has <code>data-sortable-id-value</code> for drag-and-drop tracking</li>
<li>The <code>_destroy</code> hidden field marks questions for deletion</li>
<li>The template includes the <code>_destroy</code> field for new questions too</li>
</ul>
<hr>
<p>And there you have it. A complete nested forms solutions for Rails with Stimulus that includes adding new questions, deleting and reposition them. From here it is straight-forward to add nested answers too!</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/perron-1-0/?ref=rss</id>
    <title>Perron 1.0 here is here</title>
    <link href="https://railsdesigner.com/perron-1-0/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-30T04:30:00Z</published>
    <updated>2026-04-30T04:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/perron-1-0/?ref=rss"><![CDATA[<p>Nine months ago, I introduced <a href="https://github.com/Rails-Designer/perron">Perron</a>, an <strong>OSS Rails-based static site generator</strong>. 190 commits and 18 releases later, lots of bugfixes and many new features—by yours truly and a few others—have been added. Today I am thrilled to announce <strong>Perron 1.0</strong>. 🥳</p>
<p>Want to check it out right away? <a href="https://perron.railsdesigner.com/docs/">Check out the docs</a>, <a href="https://github.com/Rails-Designer/perron">star it on GitHub</a> or <a href="https://perron.railsdesigner.com/showcase/">explore the showcase</a> to see what others have built. 😻</p>
<h2>
<a href="#programmatic-seo" aria-hidden="true" class="anchor" id="programmatic-seo"></a>Programmatic SEO</h2>
<p>One of the most exciting features has been the ability to generate content programmatically. Whether you are building a SaaS directory, product comparison pages or anything else that mixes data with thousands of pages, with Perron this is now super easy.</p>
<p><a href="https://perron.railsdesigner.com/docs/programmatic-content-creation/">Learn more about programmatic content creation</a>.</p>
<h2>
<a href="#pull-content-using-third-party-apis" aria-hidden="true" class="anchor" id="pull-content-using-third-party-apis"></a>Pull content using third-party APIs</h2>
<p>Your content does not have to live in markdown files or data files. Building on top of the same sources feature that powers Perron’s programmatic feature, you can now <a href="https://perron.railsdesigner.com/docs/programmatic-content-creation/#api-integration-with-custom-classes">pull content from third-party APIs</a>. Great to pull content from CMS’, pull data from your GitHub account or whatever else that has an API.</p>
<h2>
<a href="#preview-draft-or-scheduled-content" aria-hidden="true" class="anchor" id="preview-draft-or-scheduled-content"></a>Preview draft or scheduled content</h2>
<p>This is one of those features that arrived from a personal pain: how to share a scheduled or draft article with others? Just set <code>preview: true</code> in the resource’s frontmatter and share the private link. <a href="https://perron.railsdesigner.com/docs/publishing/#preview">Read all about it</a>.</p>
<h2>
<a href="#live-reload" aria-hidden="true" class="anchor" id="live-reload"></a>Live reload</h2>
<p>Development just got faster. 🏎️💨 Perron live reloads using DOM morphing through the <a href="https://github.com/Rails-Designer/mata">mata gem</a>. It is a framework-agnostic gem I built specifically for Perron, but can be <strong>used with any Ruby framework</strong>. Watch your changes appear instantly in the browser without a full page refresh. Will announce this gem properly later. 🤫</p>
<h2>
<a href="#but-wait-there-is-more-" aria-hidden="true" class="anchor" id="but-wait-there-is-more-"></a>But wait… there is more! 🤩</h2>
<ul>
<li>
<a href="https://perron.railsdesigner.com/docs/markdown/#processors">Processors to transform markdown</a>; add <code>target=_blank</code> to links, transform images’ paths to be absolute or create your own processors to transform HTML however you need</li>
<li>
<a href="https://perron.railsdesigner.com/docs/rendering/#activerecord-style-queries">Query your resources like Rails</a>; use familiar methods like <code>where</code>, <code>order</code> and <code>scope</code>
</li>
<li>
<a href="https://perron.railsdesigner.com/docs/data/">Work directly with data sources</a>; use data from CSV, JSON and YAML files</li>
<li>
<a href="https://perron.railsdesigner.com/docs/resources/#associations">has_many and belongs_to</a>; familiar class methods from Rails to associate resources with each other</li>
<li>
<a href="https://perron.railsdesigner.com/docs/feeds/">Feeds out of the box</a>; RSS, JSON and Atom feeds are now included out-of-the-box</li>
<li>
<a href="https://perron.railsdesigner.com/docs/markdown/">Markdown support</a>; whether you prefer <code>kramdown</code>, <code>redcarpet</code> or <code>commonmarker</code>, Perron works with all of them</li>
<li>
<a href="https://perron.railsdesigner.com/docs/related-resources/">Display related resources</a>; show related posts, products or any other resources on your pages</li>
<li>
<a href="https://perron.railsdesigner.com/docs/resources/#adjacency-nextprevious">Navigate with previous and next</a>; built-in methods to navigate through your resources in sequence</li>
</ul>
<h2>
<a href="#library-additions" aria-hidden="true" class="anchor" id="library-additions"></a>Library additions</h2>
<p>The <a href="https://perron.railsdesigner.com/library/">library</a> continues to grow with new snippets, components and templates:</p>
<ul>
<li>
<strong><a href="https://perron.railsdesigner.com/library/search-form/">Full text search form</a></strong>; add full text search to your site (also used on the docs site)</li>
<li>
<strong><a href="https://perron.railsdesigner.com/library/filter/">Filter elements</a></strong>; let your visitors filter content by category, tag or any other attribute</li>
<li>
<strong><a href="https://perron.railsdesigner.com/library/embed/">Embed content</a></strong>; useful for embedding a changelog or other content from your Perron site into your app</li>
<li>
<strong><a href="https://perron.railsdesigner.com/library/skills/">LLM skill</a></strong>; make sure your LLM agent knows its way around Perron</li>
<li>
<strong><a href="https://perron.railsdesigner.com/library/documentation/">Documentation template</a></strong>; the template that also powers the Perron docs</li>
</ul>
<h2>
<a href="#whats-next" aria-hidden="true" class="anchor" id="whats-next"></a>What’s next?</h2>
<p>Perron 1.0 is stable and ready for production (like this very site! 💡). But this is just the beginning. The <a href="https://perron.railsdesigner.com/showcase/">showcase</a> is growing with sites built by the community and there is plenty more to come. 🚀🌕</p>
<p>A huge thank you to <a href="https://github.com/ianyamey">@ianyamey</a>, <a href="https://github.com/goose3228">@goose3228</a> and <a href="https://github.com/m-lague">@m-lague</a> for their contributions and feedback along the way. ❤️</p>
<p>If you have not tried Perron yet, now is the time to hop on. <a href="https://perron.railsdesigner.com/docs/">Check out the docs</a>, <a href="https://perron.railsdesigner.com/library/">explore the library</a> and <a href="https://github.com/Rails-Designer/perron">star the repo</a>. Would love to see what you will build. 🚂</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/js-errors-feedback/?ref=rss</id>
    <title>Catch JavaScript errors with user-friendly error feedback</title>
    <link href="https://railsdesigner.com/js-errors-feedback/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-23T07:30:00Z</published>
    <updated>2026-04-23T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/js-errors-feedback/?ref=rss"><![CDATA[<p>JavaScript errors (either vanilla or with Stimulus controllers) often happen silently in the browser, leaving your users confused about what went wrong. “Why did nothing happen?”. “I just did click the button!” “Let’s try again…”. Still nothing… <em>Starts furiously clicking the button now</em>.</p>
<p>This poor user experience can be frustrating and can lead to more support tickets that could have been prevented. In this article I want to show how to build a simple class that catches unhandled JavaScript errors and displays them to the user in a friendly banner. It’s a small but meaningful improvement to your app’s user experience.</p>
<p>As always, <a href="https://github.com/rails-designer-repos/js-error-feedback">the code can be found on GitHub</a>.</p>
<h2>
<a href="#the-silence-of-the-errors" aria-hidden="true" class="anchor" id="the-silence-of-the-errors"></a>The silence of the errors</h2>
<p>When a JavaScript error occurs and isn’t caught, it silently fails in the background. The user has no idea what happened. You as a developer might inspect the browser’s console, but you are not a normie. Did the request fail? Is the app broken? Should they refresh the page? Without feedback, they’re left guessing.</p>
<p>A simple error banner at the top of the page can help with this. It tells the user something went wrong and gives them the option to dismiss it or take action.</p>
<h2>
<a href="#hello-noisy-errors" aria-hidden="true" class="anchor" id="hello-noisy-errors"></a>Hello noisy errors</h2>
<p>The <code>ErrorFeedback</code> class is straightforward. It listens for unhandled errors and promise rejections, then displays them in a banner:</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/error_feedback.js
</span><span style="font-weight:bold;color:#dc2626;">export default class </span><span style="font-weight:bold;color:#b91c1c;">ErrorFeedback </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  #banner = null
</span><span style="color:#0c4a6e;">  _timeout = null
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">constructor</span><span style="color:#475569;">(</span><span style="color:#1e293b;">options </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;">duration </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">duration </span><span style="font-weight:bold;color:#0369a1;">?? </span><span style="font-weight:bold;color:#d97706;">5000
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message </span><span style="font-weight:bold;color:#0369a1;">?? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">Something went wrong. Please try again.</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;">visibleClass </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">options</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">visibleClass </span><span style="font-weight:bold;color:#0369a1;">?? </span><span style="color:#475569;">"</span><span style="color:#0369a1;">is-visible</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;">setup</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:#0284c7;">gottaCatchThemAll</span><span style="color:#475569;">(</span><span style="color:#1e293b;">options</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">this</span><span style="color:#475569;">(</span><span style="color:#1e293b;">options</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;">setup</span><span style="color:#475569;">() {
</span><span style="color:#0c4a6e;">    window</span><span style="color:#475569;">.</span><span style="color:#0284c7;">onerror </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">msg</span><span style="color:#475569;">, </span><span style="color:#1e293b;">src</span><span style="color:#475569;">, </span><span style="color:#1e293b;">line</span><span style="color:#475569;">, </span><span style="color:#1e293b;">col</span><span style="color:#475569;">, </span><span style="color:#1e293b;">error</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;">show</span><span style="color:#475569;">(</span><span style="color:#1e293b;">msg </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">error</span><span style="font-weight:bold;color:#0369a1;">?</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message</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:#075985;">true
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    window</span><span style="color:#475569;">.</span><span style="color:#0284c7;">onunhandledrejection </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:#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;">show</span><span style="color:#475569;">(</span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">reason</span><span style="font-weight:bold;color:#0369a1;">?</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">reason</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;">show</span><span style="color:#475569;">(</span><span style="color:#1e293b;">text</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;">#</span><span style="color:#1e293b;">banner</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;">createBanner</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;">banner</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">p</span><span style="color:#475569;">").</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">text </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message
</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;">banner</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:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">visibleClass</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;">scheduleDismiss</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;">  #hide = () =&gt; {
</span><span style="color:#0c4a6e;">    </span><span style="color:#0284c7;">if </span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#0c4a6e;">.#</span><span style="color:#1e293b;">banner</span><span style="color:#475569;">) </span><span style="color:#0c4a6e;">this.#banner.classList.</span><span style="color:#0284c7;">remove</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">visibleClass</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    this.#</span><span style="color:#0284c7;">clearSchedule</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;">createBanner</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;">banner </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:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">banner</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">className </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">"</span><span style="color:#0369a1;">error-feedback</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;">banner</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;p&gt;&lt;/p&gt;
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">      &lt;button type="button" aria-label="Dismiss"&gt;×&lt;/button&gt;
</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;">banner</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">button</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;">#</span><span style="color:#1e293b;">hide</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;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">banner</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;">scheduleDismiss</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;">clearSchedule</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;">duration </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;">_timeout </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">setTimeout</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;">hide</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">duration</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;">clearSchedule</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;">_timeout</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">      </span><span style="color:#0284c7;">clearTimeout</span><span style="color:#475569;">(</span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">_timeout</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;">_timeout </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:#475569;">}
</span></code></pre>
<blockquote>
<p>[!tip]<br>
If above class is overwhelming to you, why not check out <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a>? It touches upon many of the syntax you see above.</p>
</blockquote>
<p>The class catches two types of errors: synchronous errors via <code>window.onerror</code> and promise rejections via <code>window.onunhandledrejection</code>. When an error occurs, it extracts the error message and displays it in the banner.</p>
<p>The banner automatically dismisses after a (configurable) 5 seconds.</p>
<h2>
<a href="#enable-the-banner" aria-hidden="true" class="anchor" id="enable-the-banner"></a>Enable the banner</h2>
<p>Initialize the error feedback in your main application file:</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="font-weight:bold;color:#dc2626;">import </span><span style="color:#1e293b;">ErrorFeedback </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">errors</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">ErrorFeedback</span><span style="color:#475569;">.</span><span style="color:#1e293b;">gottaCatchThemAll</span><span style="color:#475569;">() </span><span style="font-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> I am too old to fully get this reference, but I think it is accurate enough
</span></code></pre>
<p>And that is it! The class is now listening for errors across your entire app.</p>
<h2>
<a href="#where-to-go-from-here" aria-hidden="true" class="anchor" id="where-to-go-from-here"></a>Where to go from here</h2>
<p>The banner can be easily extended with additional features. You could add a link to your documentation, a button to contact support or even integrate with error monitoring tools like Appsignal or Honeybadger.</p>
<p>For example, you could add a link to your support chat:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">banner</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;p&gt;&lt;/p&gt;
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">  &lt;div&gt;
</span><span style="color:#0369a1;">    &lt;a href="https://example.com/chat"&gt;Chat with support&lt;/a&gt;
</span><span style="color:#0369a1;">
</span><span style="color:#0369a1;">    &lt;button type="button" aria-label="Dismiss"&gt;×&lt;/button&gt;
</span><span style="color:#0369a1;">  &lt;/div&gt;
</span></code></pre>
<p>Or extend the class to send errors to an external service:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">show</span><span style="color:#475569;">(</span><span style="color:#1e293b;">text</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;">#</span><span style="color:#1e293b;">banner</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;">createBanner</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;">banner</span><span style="color:#475569;">.</span><span style="color:#1e293b;">querySelector</span><span style="color:#475569;">("</span><span style="color:#0369a1;">p</span><span style="color:#475569;">").</span><span style="color:#0c4a6e;">textContent </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">text </span><span style="font-weight:bold;color:#0369a1;">|| </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">message
</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;">banner</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:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">visibleClass</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;">scheduleDismiss</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;"> Send to error monitoring service
</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;">reportError</span><span style="color:#475569;">(</span><span style="color:#1e293b;">text</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;">reportError</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-style:italic;color:#475569;">//</span><span style="font-style:italic;color:#64748b;"> Send to Appsignal, Honeybadger, etc.
</span><span style="color:#475569;">}
</span></code></pre>
<hr>
<p>This simple class is not a replacement for proper error monitoring tools. Those tools provide detailed stack traces, user session replay and analytics that are super useful for debugging sessions. But this banner fills an important gap: it gives your users immediate feedback when something goes wrong, improving their experience and reducing confusion.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/transitions-with-cubic-bezier/?ref=rss</id>
    <title>Level up CSS transitions with cubic-bezier</title>
    <link href="https://railsdesigner.com/transitions-with-cubic-bezier/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-16T07:30:00Z</published>
    <updated>2026-04-16T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/transitions-with-cubic-bezier/?ref=rss"><![CDATA[<p>If you’ve ever noticed that your CSS transitions feel a bit… flat, you’re not alone (I seem them a lot! 😭). The default <code>ease</code> timing function works fine, but it’s generic. Real-world motion has character, it bounces, overshoots and feels natural. That is what <code>cubic-bezier</code> is for.</p>
<h2>
<a href="#what-is-cubic-bezier" aria-hidden="true" class="anchor" id="what-is-cubic-bezier"></a>What is cubic-bezier?</h2>
<p>In CSS, <code>cubic-bezier</code> is used in the <code>transition-timing-function</code> or <code>animation-timing-function</code> property. It defines how intermediate values are calculated during a transition using a <a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve">cubic Bézier curve</a> (orly?! 🦉).</p>
<p>The syntax looks like this:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">cubic-bezier</span><span style="color:#0c4a6e;">(x1, y1, x2, y2)
</span></code></pre>
<p>Those four numbers define two control points on a curve. The curve starts at (0,0) and ends at (1,1), representing the beginning and end of the transition. The control points shape how the animation progresses between those states.</p>
<p>For a detailed explanation, check out the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function">MDN Web Docs</a>.</p>
<h2>
<a href="#examples-that-make-a-difference" aria-hidden="true" class="anchor" id="examples-that-make-a-difference"></a>Examples that make a difference</h2>
<p>Let’s look at some examples that use default easing functions and then show a more interesting timing functions. Hover to see the effects:</p>
<p><strong>Before</strong>:</p>
<div class="size-8 bg-orange-600 rounded-md transition duration-500 ease-in-out hover:scale-80 hover:rotate-359"></div>
<p><strong>After</strong> <a href="https://perron.railsdesigner.com/docs/">taken from Perron’s docs; see logo</a>:</p>
<div class="size-8 bg-orange-600 rounded-md transition duration-500 ease-[cubic-bezier(0.68,-0.6,0.32,1.6)] hover:scale-80 hover:rotate-359"></div>
<p>This is how it is done with Tailwind CSS:</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;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">transition duration-500 ease-[cubic-bezier(0.68,-0.6,0.32,1.6)] hover:scale-80 hover:rotate-359</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>
<p>And how you write it with CSS:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="color:#475569;">.</span><span style="color:#0369a1;">icon </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transition</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">transform </span><span style="font-weight:bold;color:#d97706;">0.5</span><span style="font-weight:bold;color:#dc2626;">s </span><span style="color:#0284c7;">cubic-bezier</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">.68 -.6 .32 1.6</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span><span style="color:#475569;">.</span><span style="color:#0369a1;">icon</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">hover </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transform</span><span style="color:#475569;">: </span><span style="color:#0284c7;">scale</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">0.8</span><span style="color:#475569;">) </span><span style="color:#0284c7;">rotate</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">359</span><span style="font-weight:bold;color:#dc2626;">deg</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p><strong>Before</strong>:</p>
<div class="size-12 bg-blue-600 rounded-md transition-transform duration-300 ease-in hover:scale-150"></div>
<p><strong>After</strong>:</p>
<div class="size-12 bg-blue-600 rounded-md transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-150"></div>
<p>This is how it is done with Tailwind CSS:</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;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-150</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>
<p>And how you write it with CSS:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="color:#475569;">.</span><span style="color:#0369a1;">button </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transition</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">transform </span><span style="font-weight:bold;color:#d97706;">0.3</span><span style="font-weight:bold;color:#dc2626;">s </span><span style="color:#0284c7;">cubic-bezier</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">.34 1.56 .64 1</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span><span style="color:#475569;">.</span><span style="color:#0369a1;">button</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">hover </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transform</span><span style="color:#475569;">: </span><span style="color:#0284c7;">scale</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">1.5</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p><strong>Before</strong>:</p>
<div class="w-32 h-8 bg-green-600 rounded-full transition-transform duration-500 ease-in-out hover:translate-x-8"></div>
<p><strong>After</strong>:</p>
<div class="w-32 h-8 bg-green-600 rounded-full transition-transform duration-500 ease-[cubic-bezier(0.87,0,0.13,1)] hover:translate-x-8"></div>
<p>This is how it is done with Tailwind CSS:</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;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">transition-transform duration-500 ease-[cubic-bezier(0.87,0,0.13,1)] hover:translate-x-8</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>
<p>And how you write it with CSS:</p>
<pre lang="css" style="background-color:#f8fafc;"><code><span style="color:#475569;">.</span><span style="color:#0369a1;">pill </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transition</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">transform </span><span style="font-weight:bold;color:#d97706;">0.5</span><span style="font-weight:bold;color:#dc2626;">s </span><span style="color:#0284c7;">cubic-bezier</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">.87 0 .13 1</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span><span style="color:#475569;">.</span><span style="color:#0369a1;">pill</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;">hover </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  transform</span><span style="color:#475569;">: </span><span style="color:#0284c7;">translateX</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#d97706;">2</span><span style="font-weight:bold;color:#dc2626;">rem</span><span style="color:#475569;">);
</span><span style="color:#475569;">}
</span></code></pre>
<p>Notice the negative values in the cubic-bezier functions? Those create the “bounce” effect that overshoots its target before settling into place and what gives that “premium” feel.</p>
<blockquote>
<p>[!note]<br>
Tailwind CSS <a href="https://tailwindcss.com/docs/transition-timing-function">provides better defaults</a> with <code>ease-{in,out,in-out}</code> classes</p>
</blockquote>
<h2>
<a href="#finding-the-perfect-curve" aria-hidden="true" class="anchor" id="finding-the-perfect-curve"></a>Finding the perfect curve</h2>
<p>Rather than guessing values, use <a href="https://easingwizard.com/">Easing Wizard</a>. It’s a beautiful, interactive tool (that I shared in my <a href="/feed/notes.xml">notes</a> some weeks ago; only accessible via atom feed 🤫) where you can drag control points and see the resulting animation in real-time. Copy the generated cubic-bezier values directly into your CSS.</p>
<p>The difference between <code>ease</code> and a well-crafted cubic-bezier curve is immediately noticeable. Your interfaces will feel more responsive, more alive and more professional.</p>
<p>Try replacing your next <code>transition: all 300s ease</code> with <code>transition: all 300s cubic-bezier(.68 -.6 .32 1.6)</code> and see the difference for yourself.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/introducing-fuik/?ref=rss</id>
    <title>Announcing Fuik: a webhook engine for Rails</title>
    <link href="https://railsdesigner.com/introducing-fuik/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-09T07:30:00Z</published>
    <updated>2026-04-09T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/introducing-fuik/?ref=rss"><![CDATA[<p>Webhooks are one of those super simple but really powerful tools that almost every developer has needed to use at least once. From Stripe, GitHub to Postmark. Setting up webhooks is not difficult, but there is quite a bit of boilerplate involved: payload storage, event routing and signature verification. Repetitive. Tedious. Boring. Oh, and did I mention debugging?</p>
<p>Meet <strong>Fuik</strong>: a Rails engine that catches and stores webhooks from any provider, then gives you a clean way to process them.</p>
<p>👉 If you want to <a href="https://github.com/rails-designer/fuik">check out the repo and star ⭐ it</a>, that would make my day! 😊</p>
<p><img src="https://railsdesigner.com/images/posts/fuik-index.jpg" alt="index/list view of Fuik; listing received events from Stripe, GitHub, Shopify and Postmark"></p>
<p>You can install Fuik simply by running <code>bundle add fuik</code> and then run the install generator and migrate the database. Seconds of work! Your app can now receive webhooks at <code>/webhooks</code>.</p>
<p>Then visit <code>/webhooks</code> to view all received webhooks. Click any event to see the full payload (that’s where Fuik has some powereful features stored for you; more on that below).</p>
<p>But just looking at your webhook payloads is not enough. Fuik maps every event with a dedicated class. Create one by running:</p>
<pre lang="bash" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">bin/rails</span><span style="color:#0c4a6e;"> generate fuik:provider stripe checkout_completed
</span></code></pre>
<p>This creates <code>app/webhooks/stripe/checkout_session_completed.rb</code>. Add your business logic:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="font-weight:bold;color:#dc2626;">module </span><span style="color:#0c4a6e;">Stripe
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">CheckoutSessionCompleted </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">Base
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">process!
</span><span style="color:#0c4a6e;">      User</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;"> payload</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">client_reference_id</span><span style="color:#475569;">).</span><span style="color:#0284c7;">tap </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">user</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">        user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">activate_subscription!
</span><span style="color:#0c4a6e;">        user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">send_welcome_email
</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:#475569;">@</span><span style="color:#1e293b;">webhook_event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">processed!
</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><img src="https://railsdesigner.com/images/posts/fuik-show.jpg" alt="Detail view on Fuik’s UI; showing a Shopify webhook event for an order/create event"></p>
<p>Fuik’s UI has quite a few more trick up its sleeve to help you (or your LLM  friend) debug your webhook issues. 🤖⚡</p>
<ul>
<li>
<strong>Copy payload as JSON</strong>: click a button, payload is in your clipboard</li>
<li>
<strong>Download payload as JSON file</strong>: keep it for testing, debugging or throw it at your LLM agent, bot or colleague.</li>
<li>
<strong>Add <code>.json</code> to any URL</strong>: get the raw payload without the UI</li>
<li>
<strong>Click any key to get the Ruby accessor path</strong>: click <code>product_id</code> (as seen in the above screenshot) in the dashboard and get <code>payload["line_items"][0]["product_id"]</code> (say what? 🤯)</li>
</ul>
<p>Fuik includes templates for a small selection of providers I needed. Built one yourself? Upstream it and submit a PR. Help your fellow developer by contributing to OSS. ❤️</p>
<hr>
<p>Already using Fuik? <a href="https://github.com/rails-designer/fuik">Star it on GitHub</a>. ⭐</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/form-service-objects/?ref=rss</id>
    <title>Form Objects vs Service Objects in Rails</title>
    <link href="https://railsdesigner.com/form-service-objects/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-08T07:30:00Z</published>
    <updated>2026-04-08T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/form-service-objects/?ref=rss"><![CDATA[<p>When building a SaaS with Rails, I often have more complex logic that spans multiple models and actions. Over time, I’ve settled on two distinct patterns: <strong>Form Objects</strong> and what I’ll just call <strong>classes</strong> (or POROs). They serve different purposes, and knowing when to reach for each has made my code easier to reason about and test.</p>
<p>Let me walk you through how I use them both.</p>
<h2>
<a href="#use-form-objects-for-user-input" aria-hidden="true" class="anchor" id="use-form-objects-for-user-input"></a>Use Form Objects for user input</h2>
<p>I use Form Objects when I have a form that a user interacts with directly, and that form needs to create (or update) more than one record. The key is the direct mapping to the form itself.</p>
<p>A Form Object should “quack” like an Active Record model. It validates input, it responds to <code>#save</code>, and it works seamlessly with Rails’ form helpers. Here’s an example:</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/signup.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Signup </span><span style="color:#475569;">&lt; </span><span style="color:#0c4a6e;">ApplicationForm
</span><span style="color:#0c4a6e;">  attribute </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:#475569;">:</span><span style="font-weight:bold;color:#075985;">string
</span><span style="color:#0c4a6e;">  attribute </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">email_address</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">string
</span><span style="color:#0c4a6e;">  attribute </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">password</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">string
</span><span style="color:#0c4a6e;">  attribute </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">terms</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">boolean</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">default</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">  attribute </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">receive_product_updates</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">boolean</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">default</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">false
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  normalizes </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">email_address</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">with</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;"> it</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">strip</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">downcase </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;">name</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">email_address</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">password</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">presence</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#075985;">true
</span><span style="color:#0c4a6e;">  validates_format_of </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">email_address</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">with</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">URI</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">MailTo</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">EMAIL_REGEXP
</span><span style="color:#0c4a6e;">  validates </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">password</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">length</span><span style="font-weight:bold;color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">8</span><span style="font-weight:bold;color:#0369a1;">..</span><span style="font-weight:bold;color:#d97706;">128
</span><span style="color:#0c4a6e;">  validates </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">terms</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">acceptance</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">{ </span><span style="font-weight:bold;color:#075985;">message</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">and privacy policy need to be accepted</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;">save
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">if</span><span style="color:#0c4a6e;"> valid?
</span><span style="color:#0c4a6e;">      transaction </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">        </span><span style="color:#475569;">@</span><span style="color:#1e293b;">user </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> create_user</span><span style="color:#475569;">.</span><span style="color:#0284c7;">tap </span><span style="color:#475569;">{</span><span style="color:#0c4a6e;"> it</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">setup_workspace</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#075985;">member_name</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0284c7;">name</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">save </span><span style="color:#475569;">}.</span><span style="color:#0284c7;">tap </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">user</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">          user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create_preferences</span><span style="color:#475569;">.</span><span style="color:#0284c7;">tap </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">preferences</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">            preferences</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">update </span><span style="font-weight:bold;color:#075985;">receive_product_updates</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> receive_product_updates
</span><span style="color:#0c4a6e;">          </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">          send_welcome_email_to 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="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;">attr_reader </span><span style="font-weight:bold;color:#475569;">:</span><span style="font-weight:bold;color:#075985;">user
</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;">create_user</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">User</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">create</span><span style="color:#0c4a6e;">!(</span><span style="color:#1e293b;">email_address</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;"> email_address</span><span style="color:#475569;">, </span><span style="color:#1e293b;">password</span><span style="color:#475569;">:</span><span style="color:#0c4a6e;"> password</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;">send_welcome_email_to</span><span style="color:#475569;">(</span><span style="color:#1e293b;">user</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">WelcomeEmailJob</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">perform_later user</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">email_address
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>See the inherited <code>ApplicationForm</code>? That is what makes most of the “quacking” possible (iirc, I got this approach from the Layered Rails Design book):</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;">ApplicationForm
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#0c4a6e;">ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Model
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#0c4a6e;">ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Attributes
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#0c4a6e;">ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Attributes</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Normalization
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">include </span><span style="color:#0c4a6e;">ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Validations</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Callbacks
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">model_name
</span><span style="color:#0c4a6e;">    ActiveModel</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">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:#1e293b;">self</span><span style="color:#475569;">.</span><span style="color:#0284c7;">class</span><span style="color:#475569;">.</span><span style="color:#0284c7;">name</span><span style="color:#475569;">.</span><span style="color:#0284c7;">sub</span><span style="color:#475569;">(/</span><span style="color:#0369a1;">Form$</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;">transaction</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">&amp;</span><span style="color:#1e293b;">block</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">ActiveRecord</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Base</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">transaction</span><span style="color:#475569;">(</span><span style="font-weight:bold;color:#0369a1;">&amp;</span><span style="color:#0c4a6e;">block</span><span style="color:#475569;">)
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>In the controller, it works just like you’d expect:</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;">SignupsController </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;">new
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">signup </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Signup</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new
</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;">create
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">signup </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0c4a6e;">Signup</span><span style="color:#475569;">.</span><span style="font-weight:bold;color:#dc2626;">new</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">signup_params</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:#0c4a6e;"> user </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">@</span><span style="color:#1e293b;">signup</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">save
</span><span style="color:#0c4a6e;">      start_new_session_for user
</span><span style="color:#0c4a6e;">      redirect_to root_url
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">else
</span><span style="color:#0c4a6e;">      redirect_to new_signups_path
</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;">signup_params</span><span style="color:#475569;">; </span><span style="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>The Form Object has all the logic needed to handle the signup flow. Validations, record creation, side effects live in one place that mirrors the form itself.</p>
<h2>
<a href="#for-everything-else-use-poros" aria-hidden="true" class="anchor" id="for-everything-else-use-poros"></a>For everything else use POROs</h2>
<p>For logic that isn’t tied to a specific form, I just write a regular class. I avoid the term “Service Object” and the suffix “Service” altogether (personally hate the term!). Instead, I name it after what it actually does and I like to namespace them if related to another class (or ActiveRecord model).</p>
<p>Consider inviting a user to a workspace. This isn’t a form submission; it’s an action triggered programmatically (but it could be called from a form object). Here’s how I’d handle it:</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/member/setup.rb
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">Member</span><span style="font-weight:bold;color:#475569;">::</span><span style="font-weight:bold;color:#b91c1c;">Setup
</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;">workspace</span><span style="color:#475569;">:, </span><span style="color:#1e293b;">user</span><span style="color:#475569;">:, </span><span style="color:#1e293b;">role</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">member</span><span style="color:#475569;">", </span><span style="color:#1e293b;">name</span><span style="color:#475569;">: "</span><span style="color:#0369a1;">Unknown</span><span style="color:#475569;">")
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">workspace </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> workspace
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">user </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> user
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">@</span><span style="color:#1e293b;">role </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> role</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="color:#475569;">@</span><span style="color:#1e293b;">name </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#0284c7;">name
</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;">save
</span><span style="color:#0c4a6e;">    ActiveRecord</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Base</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">transaction </span><span style="font-weight:bold;color:#dc2626;">do
</span><span style="color:#0c4a6e;">      create_member</span><span style="color:#475569;">.</span><span style="color:#0284c7;">tap </span><span style="font-weight:bold;color:#dc2626;">do </span><span style="color:#475569;">|</span><span style="color:#1e293b;">member</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">        add_roles_to member
</span><span style="color:#0c4a6e;">        mark_workspace_current
</span><span style="color:#0c4a6e;">        create_profile_for member
</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="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;">create_member</span><span style="color:#0c4a6e;"> = </span><span style="color:#1e293b;">Member</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">create</span><span style="color:#0c4a6e;">(</span><span style="color:#1e293b;">workspace</span><span style="color:#475569;">: @</span><span style="color:#1e293b;">workspace</span><span style="color:#475569;">, </span><span style="color:#1e293b;">user</span><span style="color:#475569;">: @</span><span style="color:#1e293b;">user</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;">add_roles_to</span><span style="color:#475569;">(</span><span style="color:#1e293b;">member</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    roles</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;">role_name</span><span style="color:#475569;">|
</span><span style="color:#0c4a6e;">      member</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">actors</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create </span><span style="font-weight:bold;color:#075985;">role</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#0c4a6e;">Role</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">where</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:#0c4a6e;"> role_name</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">first_or_create
</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;">mark_workspace_current</span><span style="color:#0c4a6e;"> = @</span><span style="color:#1e293b;">user</span><span style="color:#0c4a6e;">.</span><span style="color:#1e293b;">update</span><span style="color:#0c4a6e;">!(</span><span style="color:#1e293b;">workspace</span><span style="color:#475569;">: @</span><span style="color:#1e293b;">workspace</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;">create_profile_for</span><span style="color:#475569;">(</span><span style="color:#1e293b;">member</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#0369a1;">=</span><span style="color:#0c4a6e;"> member</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">create_profile</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:#1e293b;">name</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;">roles
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#075985;">administrator</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">administrator</span><span style="color:#475569;">],
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#075985;">member</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">member</span><span style="color:#475569;">],
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#075985;">owner</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">%w[</span><span style="color:#0369a1;">administrator billing member owner</span><span style="color:#475569;">]
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">}[@</span><span style="color:#1e293b;">role</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="font-weight:bold;color:#dc2626;">end
</span><span style="font-weight:bold;color:#dc2626;">end
</span></code></pre>
<p>Notice the naming: <code>Member::Setup</code>. No “Service” suffix, just the thing it does, namespaced under the model it primarily concerns.</p>
<p>This class isn’t invoked directly. Instead, I add a method to the <code>Workspace</code> model:</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;">Workspace </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;">members</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">dependent</span><span style="font-weight:bold;color:#475569;">: :</span><span style="font-weight:bold;color:#075985;">destroy
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">def </span><span style="color:#0284c7;">add_member</span><span style="color:#475569;">(</span><span style="color:#1e293b;">to</span><span style="color:#475569;">:, </span><span style="color:#1e293b;">role</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">, </span><span style="color:#1e293b;">name</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#075985;">nil</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    Member</span><span style="color:#475569;">::</span><span style="color:#0c4a6e;">Setup</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;">workspace</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#1e293b;">self</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">user</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> to</span><span style="color:#475569;">, </span><span style="font-weight:bold;color:#075985;">role</span><span style="font-weight:bold;color:#475569;">:</span><span style="color:#0c4a6e;"> role</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:#0284c7;">name</span><span style="color:#475569;">).</span><span style="color:#0c4a6e;">save
</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 the API is clean and expressive:</p>
<pre lang="ruby" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">workspace</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">add_member User</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">first
</span><span style="color:#0c4a6e;">workspace</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">add_member 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;">2</span><span style="color:#475569;">), </span><span style="font-weight:bold;color:#075985;">role</span><span style="font-weight:bold;color:#475569;">: </span><span style="color:#475569;">"</span><span style="color:#0369a1;">administrator</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;">Alice</span><span style="color:#475569;">"
</span></code></pre>
<h2>
<a href="#one-place-to-rule-them-all" aria-hidden="true" class="anchor" id="one-place-to-rule-them-all"></a>One place to rule them all</h2>
<p>I keep everything in <code>app/models</code>. No <code>app/services</code> folder and or <code>app/forms</code>. This keeps my project structure flat and makes it obvious that these are domain logic, not infrastructure.</p>
<p>Here is a simple table to summarize this, already short, article. 😅</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Form objects</th>
<th>POROs</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>When</strong></td>
<td>Direct response to user input via a form</td>
<td>Business logic not tied to a form</td>
</tr>
<tr>
<td><strong>Naming</strong></td>
<td>Match the form (<code>Signup</code>, <code>ProjectInvite</code>)</td>
<td>What it does (<code>Member::Setup</code>, <code>Invoice::Generate</code>)</td>
</tr>
<tr>
<td><strong>Return value</strong></td>
<td>Always responds to <code>#save</code>; typically returns an AR model</td>
<td>Depends on use case; could be an object or <code>true</code>/<code>false</code>
</td>
</tr>
<tr>
<td><strong>Invocation</strong></td>
<td>Directly from controller</td>
<td>From other classes or model methods</td>
</tr>
<tr>
<td><strong>Location</strong></td>
<td><code>app/models/</code></td>
<td>
<code>app/models/</code>, namespaced as needed</td>
</tr>
</tbody>
</table>
<hr>
<p>This approach gives me clarity about what each class does and when to reach for it. It is just enough organisation. Form Objects handle forms and their side effects. Classes handle everything else. Both live where they belong, named clearly and no ugly suffixes required.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/refactoring-js-class/?ref=rss</id>
    <title>Refactoring a JavaScript class</title>
    <link href="https://railsdesigner.com/refactoring-js-class/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-04-02T07:30:00Z</published>
    <updated>2026-04-02T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/refactoring-js-class/?ref=rss"><![CDATA[<p>This article is taken from the book <a href="https://javascriptforrails.com/">JavaScript for Rails Developers</a> (<strong>use <code>ONE-YEAR-OLD</code> to get 25% discount</strong>; valid until 2025-04-09 🥳). It is a book I published about a year ago. Over that period, many hundreds bought the book. It is written for Ruby/Rails developers to make JavaScript your 2nd favourite language.</p>
<p>I always get a little excited when I see a good refactoring happen. So I want to share this article; it is one of the last chapters where I go over an exisintg part of the code that is created in the book to refactor it with the goal to make it:</p>
<ul>
<li>more readable;</li>
<li>easier to understand at a glance.</li>
</ul>
<p>This is the current code it started with:</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;">Annotation</span><span style="color:#475569;">, </span><span style="color:#1e293b;">Transaction </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@codemirror/state</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">EditorView </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">codemirror</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">editorCache </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  pairs</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#0c4a6e;">WeakMap</span><span style="color:#475569;">(),
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">store</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sourceEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">duplicateEditor</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;">pairs</span><span style="color:#475569;">.</span><span style="color:#1e293b;">set</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sourceEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">duplicateEditor</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;">get</span><span style="color:#475569;">(</span><span style="color:#1e293b;">editor</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;">pairs</span><span style="color:#475569;">.</span><span style="color:#1e293b;">get</span><span style="color:#475569;">(</span><span style="color:#1e293b;">editor</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;">export const </span><span style="color:#1e293b;">splitView </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">for</span><span style="color:#475569;">: (</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">, { </span><span style="color:#1e293b;">enabled </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="font-weight:bold;color:#0369a1;">!</span><span style="color:#1e293b;">enabled</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;">syncAnnotation </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">Annotation</span><span style="color:#475569;">.</span><span style="color:#1e293b;">define</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#0284c7;">sync </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, </span><span style="color:#1e293b;">sourceEditor</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;">const </span><span style="color:#1e293b;">documentLengthMismatch </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length </span><span style="font-weight:bold;color:#0369a1;">!== </span><span style="color:#1e293b;">sourceEditor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">state</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">doc</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">length
</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;">documentLengthMismatch</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;">sourceEditor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">update</span><span style="color:#475569;">([</span><span style="color:#1e293b;">transaction</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;">hasContentChanges </span><span style="font-weight:bold;color:#0369a1;">= !</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">empty
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">isUserChange </span><span style="font-weight:bold;color:#0369a1;">= !</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#1e293b;">annotation</span><span style="color:#475569;">(</span><span style="color:#1e293b;">syncAnnotation</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;">hasContentChanges </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">isUserChange</span><span style="color:#475569;">) {
</span><span style="color:#0c4a6e;">        </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">targetEditor </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">editorCache</span><span style="color:#475569;">.</span><span style="color:#1e293b;">get</span><span style="color:#475569;">(</span><span style="color:#1e293b;">sourceEditor</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;">targetEditor</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;">annotations </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[
</span><span style="color:#0c4a6e;">          </span><span style="color:#1e293b;">syncAnnotation</span><span style="color:#475569;">.</span><span style="color:#1e293b;">of</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;">transaction</span><span style="color:#475569;">.</span><span style="color:#1e293b;">annotation</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">userEvent</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">        </span><span style="color:#475569;">].</span><span style="color:#1e293b;">filter</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Boolean</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">        </span><span style="color:#1e293b;">targetEditor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">dispatch</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">          changes</span><span style="color:#475569;">: </span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">          </span><span style="color:#1e293b;">annotations
</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="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">newEditor </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">EditorView</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">      state</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">state</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">      parent</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dom</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:#0284c7;">dispatch</span><span style="color:#475569;">: </span><span style="color:#1e293b;">transaction </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">sync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newEditor</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;">const </span><span style="color:#1e293b;">duplicateEditor </span><span style="font-weight:bold;color:#0369a1;">= new </span><span style="color:#1e293b;">EditorView</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">      state</span><span style="color:#475569;">: </span><span style="color:#1e293b;">newEditor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">state</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">      parent</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dom</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:#0284c7;">dispatch</span><span style="color:#475569;">: </span><span style="color:#1e293b;">transaction </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">sync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, </span><span style="color:#1e293b;">duplicateEditor</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;">editorCache</span><span style="color:#475569;">.</span><span style="color:#1e293b;">store</span><span style="color:#475569;">(</span><span style="color:#1e293b;">newEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">duplicateEditor</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">editorCache</span><span style="color:#475569;">.</span><span style="color:#1e293b;">store</span><span style="color:#475569;">(</span><span style="color:#1e293b;">duplicateEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">newEditor</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">destroy</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;">duplicateEditor
</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;">destroy</span><span style="color:#475569;">: (</span><span style="color:#1e293b;">editor</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;">editor</span><span style="font-weight:bold;color:#0369a1;">?</span><span style="color:#475569;">.</span><span style="color:#1e293b;">destroy</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">}
</span><span style="color:#475569;">}
</span></code></pre>
<p>It takes a lot brainpower to understand or just scan what is happening and how things flow. I can see a named export for <code>splitView</code> that has two methods: <code>for</code> and <code>destroy</code>. And only <code>destroy</code> is instantly clear to me. That whole <code>for</code> method takes some serious computing time. By the time, a few weeks later, you need to make changes again to this file (or someone else from your team), you already have forgotten what the flow of the code was.</p>
<p>So here is my proposed, refactored class. Below it I will highlight some of the details.</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;">Annotation</span><span style="color:#475569;">, </span><span style="color:#1e293b;">Transaction </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">@codemirror/state</span><span style="color:#475569;">"
</span><span style="font-weight:bold;color:#dc2626;">import </span><span style="color:#475569;">{ </span><span style="color:#1e293b;">EditorView </span><span style="color:#475569;">} </span><span style="font-weight:bold;color:#dc2626;">from </span><span style="color:#475569;">"</span><span style="color:#0369a1;">codemirror</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">
</span><span style="font-weight:bold;color:#dc2626;">class </span><span style="font-weight:bold;color:#b91c1c;">SplitViewClass </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">create</span><span style="color:#475569;">(</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">enabled</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;">enabled</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;">firstEditor </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;">createClone</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">from</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">secondEditor </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;">createClone</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">from</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor </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;">pair</span><span style="color:#475569;">(</span><span style="color:#1e293b;">firstEditor</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">secondEditor </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">destroy</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;">secondEditor
</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;">  #pairs = new </span><span style="color:#0284c7;">WeakMap</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">  #syncAnnotation = Annotation.</span><span style="color:#0284c7;">define</span><span style="color:#475569;">()
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#0284c7;">createClone</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">from</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">sourceEditor </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    </span><span style="font-weight:bold;color:#dc2626;">return </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">EditorView</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">      state</span><span style="color:#475569;">: </span><span style="color:#1e293b;">sourceEditor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">state</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">      parent</span><span style="color:#475569;">: </span><span style="color:#1e293b;">sourceEditor</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">dom</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:#0284c7;">dispatch</span><span style="color:#475569;">: (</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, </span><span style="color:#1e293b;">editor</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:#0284c7;">sync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, { </span><span style="color:#1e293b;">to</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">editor </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:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">pair</span><span style="color:#475569;">(</span><span style="color:#1e293b;">firstEditor</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">secondEditor </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    this.#pairs.</span><span style="color:#0284c7;">set</span><span style="color:#475569;">(</span><span style="color:#1e293b;">firstEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">secondEditor</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    this.#pairs.</span><span style="color:#0284c7;">set</span><span style="color:#475569;">(</span><span style="color:#1e293b;">secondEditor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">firstEditor</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;">sync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, { </span><span style="color:#1e293b;">to</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">firstEditor </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;">cannotSync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">to</span><span style="color:#475569;">: </span><span style="color:#1e293b;">firstEditor </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;">firstEditor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">update</span><span style="color:#475569;">([</span><span style="color:#1e293b;">transaction</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;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">empty </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; !</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#1e293b;">annotation</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;">syncAnnotation</span><span style="color:#475569;">)) {
</span><span style="color:#0c4a6e;">      </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">secondEditor </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;">pairs</span><span style="color:#475569;">.</span><span style="color:#1e293b;">get</span><span style="color:#475569;">(</span><span style="color:#1e293b;">firstEditor</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;">secondEditor</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;">secondEditor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">dispatch</span><span style="color:#475569;">({
</span><span style="color:#0c4a6e;">        changes</span><span style="color:#475569;">: </span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">changes</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">        annotations</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:#0284c7;">createAnnotations</span><span style="color:#475569;">({ </span><span style="color:#1e293b;">from</span><span style="color:#0c4a6e;">: </span><span style="color:#1e293b;">transaction </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:#475569;">}
</span><span style="color:#0c4a6e;">  }
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">cannotSync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">to</span><span style="color:#475569;">: </span><span style="color:#1e293b;">firstEditor </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">		return transaction.changes?.length !== firstEditor.state.doc.</span><span style="color:#1e293b;">length
</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;">createAnnotations</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">from</span><span style="color:#475569;">: </span><span style="color:#1e293b;">transaction </span><span style="color:#475569;">}) {
</span><span style="color:#0c4a6e;">    return </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;">syncAnnotation</span><span style="color:#475569;">.</span><span style="color:#1e293b;">of</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;">transaction</span><span style="color:#475569;">.</span><span style="color:#1e293b;">annotation</span><span style="color:#475569;">(</span><span style="color:#0c4a6e;">Transaction</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">userEvent</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">]</span><span style="color:#0c4a6e;">.</span><span style="color:#0284c7;">filter</span><span style="color:#475569;">(</span><span style="color:#1e293b;">Boolean</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;">export const splitView </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">for</span><span style="color:#475569;">: (</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">, { </span><span style="color:#1e293b;">enabled </span><span style="color:#475569;">}) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="font-weight:bold;color:#0369a1;">new </span><span style="color:#1e293b;">SplitViewClass</span><span style="color:#475569;">().</span><span style="color:#1e293b;">create</span><span style="color:#475569;">(</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">, { </span><span style="color:#1e293b;">enabled </span><span style="color:#475569;">}),
</span><span style="color:#0c4a6e;">  </span><span style="color:#0284c7;">destroy</span><span style="color:#475569;">: (</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">) </span><span style="font-weight:bold;color:#dc2626;">=&gt; </span><span style="color:#1e293b;">editor</span><span style="font-weight:bold;color:#0369a1;">?</span><span style="color:#475569;">.</span><span style="color:#1e293b;">destroy</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>In terms of lines of code, they are very much the same (70 vs 71; refactored is longer!). But I argue the refactored version is way easier to grasp. Just one public method, <code>create</code>, that is readable.</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">create</span><span style="color:#475569;">(</span><span style="color:#1e293b;">editor</span><span style="color:#475569;">, </span><span style="color:#1e293b;">enabled</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;">enabled</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;">firstEditor </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;">createClone</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">from</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">secondEditor </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;">createClone</span><span style="color:#475569;">({ </span><span style="color:#0c4a6e;">from</span><span style="color:#475569;">: </span><span style="color:#1e293b;">editor </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;">pair</span><span style="color:#475569;">(</span><span style="color:#1e293b;">firstEditor</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">with</span><span style="color:#475569;">: </span><span style="color:#1e293b;">secondEditor </span><span style="color:#475569;">})
</span><span style="color:#0c4a6e;">
</span><span style="color:#0c4a6e;">  </span><span style="color:#1e293b;">editor</span><span style="color:#475569;">.</span><span style="color:#1e293b;">destroy</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;">secondEditor
</span><span style="color:#475569;">}
</span></code></pre>
<p>It is actual readable: return when not enabled. Then create one cloned editor from the (given) editor, then another one stored as <code>secondEditor</code>. Then pair the first editor with the second editor.</p>
<p>From here you can follow the code as as you need more details as the methods are ordered by level of abstraction: <code>createClone</code>, <code>pair</code>. And then methods invoked from there. Using named parameters like <code>{ from: ... }</code> and <code>{ with: ... }</code> really helps with making the intent clear.</p>
<p>Look at this early return:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><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;">cannotSync</span><span style="color:#475569;">(</span><span style="color:#1e293b;">transaction</span><span style="color:#475569;">, { </span><span style="color:#0c4a6e;">to</span><span style="color:#475569;">: </span><span style="color:#1e293b;">firstEditor </span><span style="color:#475569;">})) </span><span style="font-weight:bold;color:#dc2626;">return
</span></code></pre>
<p>Chef’s kiss, am I right?</p>
<p>Also small, focused methods that don’t give you a headache! Each method does one thing, uses early returns for clarity so no complex conditionals. And what’s more, as done earlier in chapter 8.2, the API stays the same. So to use this class with the bundled resource, simply replace: <code>import { splitView } from "./editor/splitView"</code> with <code>import { splitView } from "./editor/splitViewClass"</code>.</p>
<p><code>destroy</code> just calls the editor’s built-in <code>destroy()</code> method (using optional chaining <code>?.</code> (as covered in an earlier chapter) in case the editor is <em>undefined</em>), without needing to handle any cleanup of our pairs since the <code>WeakMap</code> automatically handles that when editors are garbage collected.</p>
<hr>
<p>A <a href="/refactoring-js-pt1/">previous refactoring article from the book can be read here</a>.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/reintroduce-rails-icons/?ref=rss</id>
    <title>Everyone, say “hi” again to Rails Icons 👋</title>
    <link href="https://railsdesigner.com/reintroduce-rails-icons/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-03-26T07:30:00Z</published>
    <updated>2026-03-26T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/reintroduce-rails-icons/?ref=rss"><![CDATA[<p>Late 2024 I published <a href="/rails-icons-one/">Rails Icons 1.0</a> (slighly off-topic, but I mentioned a modest few thousand downloads in that article; now it is close to 250k; I expect the same + a bit more for <a href="https://perron.railsdesigner.com/">Perron</a>). Today I like to re-introduce Rails Icons again to you: <a href="https://github.com/Rails-Designer/rails_icons/">here is version 1.8</a>.</p>
<p>(I did not expect SVG icons could be this exciting; and I have a few more ideas still! 🤯)</p>
<p>First I extracted the sync and SVG creation logic (<a href="/note/1772604707/">the bit people have copy-pasted instead of using/contributing to the gem</a>) into its own gem: <a href="https://github.com/Rails-Designer/icons/">Icons</a>, I <a href="/introducing-icons-gem/">wrote about it here</a>. The main goal was to make the “behind-the-scenes” architecture (syncing, normalizing icon names, etc.) available for others to build a similar gem for their framework of choice like Hanami, Roda and so on. That logic is novel and could be used in any kind of Ruby app. This keeps the Rails Icons even more light-weight and allows me to add features I want to share with you today.</p>
<p>Like (experimental) support for Ruby LSP.</p>
<p><img src="https://railsdesigner.com/images/posts/railsicons-lsp.jpg" alt=""></p>
<p>This allows you to write <code>icon "|"</code> in your IDE and it will show a completion dialog for all synced icon names. This works also with variant and library name. It is still early stage and would love some help from you if you are experienced with Ruby LSP. 🙏</p>
<p>Then a feature I have had on my list for well over a year: a preview of all your icons 😻</p>
<p>Simply add <code>mount RailsIcons::Engine =&gt; "rails_cons"</code> to your routes and you are presented with this lovely view:</p>
<p><img src="https://railsdesigner.com/images/posts/railsicons-preview.jpg" alt=""></p>
<p>Here you can:</p>
<ul>
<li>search by icon name and by related keyword (added small mappings for all Rails Icons’ supported libraries, hence you can search for <code>nature</code>, <code>vehicle</code> and <code>travel</code> 😊)</li>
<li>copy name</li>
<li>copy helper (so for above screenshot it copies: <code>icon('bulldozer')</code>, and it will add the variant if that one is not your configured default variant 💡)</li>
<li>query is stored in url so it shareable with your team (of LLM bots 😭)</li>
</ul>
<p>I think this is pretty cool! 😎</p>
<p>I thought these <del>two</del>three big features were well worth a re-introduction. If you haven’t checked it out, <a href="https://github.com/Rails-Designer/rails_icons">it is here on GitHub</a>. Be sure to star ⭐ it! ✌️</p>
<p>If you are already using Rails Icons, be sure <a href="https://github.com/Rails-Designer/rails_icons/issues/82">to add your project to the list</a>. Downloads and stars are nice and all for the larger community, usage in real projects are a true motivator for the maintainer (i.e. me).</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/custom-emoji-stimulus/?ref=rss</id>
    <title>Build custom emojis with Stimulus</title>
    <link href="https://railsdesigner.com/custom-emoji-stimulus/?ref=rss" rel="alternate" type="text/html"/>
    <published>2026-03-19T07:30:00Z</published>
    <updated>2026-03-19T07:30:00Z</updated>

    
      <author>
        <name>Rails Designer</name>
        <email>support@railsdesigner.com</email>
      </author>
    

    
    
      <content type="html" xml:base="https://railsdesigner.com/custom-emoji-stimulus/?ref=rss"><![CDATA[<p>Recently I had to build something using the canvas element. Today I’ll show you how to build an interactive emoji composer using HTML Canvas and Stimulus. Click emojis to add them to a canvas, drag them around and export your creation as an image. It is quite fun (coming from an emoji connoisseur)! 😎👍</p>
<p><img src="https://railsdesigner.com/images/posts/emojis.jpg" alt=""></p>
<p>Click a face emoji, add some accessories, position everything just right and download your custom creation. All running in the browser with no external dependencies.</p>
<p><a href="https://github.com/rails-designer-repos/emoji">View the full code on GitHub</a></p>
<h2>
<a href="#the-html" aria-hidden="true" class="anchor" id="the-html"></a>The HTML</h2>
<p>Start with a simple layout that divides the screen into a canvas area and emoji picker:</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;">canvas-composer image-exporter</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;">grid grid-cols-[1fr_clamp(240px,12%,480px)] gap-8</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;">canvas
</span><span style="color:#0c4a6e;">        </span><span style="color:#0369a1;">data-canvas-composer-target</span><span style="color:#475569;">="</span><span style="color:#0369a1;">canvas</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">        </span><span style="color:#0369a1;">width</span><span style="color:#475569;">="</span><span style="color:#0369a1;">300</span><span style="color:#475569;">"
</span><span style="color:#0c4a6e;">        </span><span style="color:#0369a1;">height</span><span style="color:#475569;">="</span><span style="color:#0369a1;">300</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;">mousedown-&gt;canvas-composer#selectEmoji mousemove-&gt;canvas-composer#moveSelectedEmoji mouseup-&gt;canvas-composer#deselectEmoji</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;">canvas</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;">grid grid-cols-2 mt-2 gap-x-3</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;">click-&gt;canvas-composer#clearAll</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">          Clear canvas
</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:#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;image-exporter#download</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">          Save as image
</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;">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;">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;">div </span><span style="color:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid grid-cols-2 gap-6</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:#0369a1;">class</span><span style="color:#475569;">="</span><span style="color:#0369a1;">grid grid-cols-2 gap-1</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;">button</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;">%w[</span><span style="color:#0369a1;">😀 😊 😠 😮 😳 😉 😟 😐 🥺 🙄 👶 👦 👧 🧑 👨 👩 🧓 👴 👵 🤖</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;">emoji </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:#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-2 gap-1</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;">button</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;">%w[</span><span style="color:#0369a1;">👑 🎩 👒 🧢 ⛑️ 👓 🕶️ 🥽 💄 📣 👄 💧 👅 👂 👃 👁️ ☂️ 👍 🖕 👋</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;">emoji </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;">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;">div</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 canvas handles mouse events for selecting and dragging emojis. On the right I added the emoji picker with faces, accessories and body parts. Each emoji button gets rendered through a simple 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;"> locals: (emoji:) %&gt;
</span><span style="color:#475569;">&lt;</span><span style="font-weight:bold;color:#dc2626;">li</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-emoji</span><span style="color:#475569;">="&lt;%=</span><span style="color:#0369a1;"> emoji </span><span style="color:#475569;">%&gt;" </span><span style="color:#0369a1;">data-action</span><span style="color:#475569;">="</span><span style="color:#0369a1;">canvas-composer#addEmoji</span><span style="color:#475569;">"&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#475569;">&lt;%=</span><span style="color:#0c4a6e;"> emoji </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;">li</span><span style="color:#475569;">&gt;
</span></code></pre>
<h2>
<a href="#html-canvas" aria-hidden="true" class="anchor" id="html-canvas"></a>HTML Canvas?</h2>
<p>Before diving into the Stimulus controller, let’s talk about the <code>&lt;canvas&gt;</code> element. Canvas provides a drawing surface where you can render graphics, text and images using JavaScript. Like a piece of paper that your code can draw on.</p>
<p>Canvas uses a coordinate system starting from the top-left corner (0,0). The x-axis runs left to right, y-axis runs top to bottom. No surprise here! To draw on canvas, you get its 2D rendering context and use methods like <code>fillText()</code>, <code>drawImage()</code> and <code>clearRect()</code>.</p>
<p>What makes canvas perfect for this emoji composer? You can draw text (emojis) at specific positions, clear and redraw the entire canvas efficiently and export the final result as an image. Canvas also handles layering naturally. Elements drawn later appear on top.</p>
<p>The key canvas methods you’ll see:</p>
<ul>
<li>
<code>getContext("2d")</code> - gets the drawing context</li>
<li>
<code>clearRect()</code> - clears a rectangular area</li>
<li>
<code>fillText()</code> - draws text at specified coordinates</li>
</ul>
<h2>
<a href="#the-stimulus-controller" aria-hidden="true" class="anchor" id="the-stimulus-controller"></a>The Stimulus controller</h2>
<p>The main controller manages the emoji canvas and drag-and-drop functionality:</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;">targets </span><span style="color:#0c4a6e;">= ["canvas"]
</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;">= { emojiSize: { type: Number, default: 100 </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;">emojisOnCanvas </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">[]
</span><span style="color:#0c4a6e;">  #</span><span style="color:#1e293b;">selectedEmoji </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;">isDragging </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;">dragOffset </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{ </span><span style="color:#0c4a6e;">x</span><span style="color:#475569;">: </span><span style="font-weight:bold;color:#d97706;">0</span><span style="color:#475569;">, </span><span style="color:#0c4a6e;">y</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:#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;">redrawCanvas</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 controller tracks emojis on the canvas, which emoji is currently selected, drag state and the offset for smooth dragging. Of course private methods as you are used to from me.</p>
<h2>
<a href="#adding-emojis-to-the-canvas" aria-hidden="true" class="anchor" id="adding-emojis-to-the-canvas"></a>Adding emojis to the canvas</h2>
<p>When you click an emoji button, it gets added to the center of the canvas:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">addEmoji</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;">center </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;">canvasCenter
</span><span style="color:#0c4a6e;">  </span><span style="font-weight:bold;color:#dc2626;">const </span><span style="color:#1e293b;">emoji </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">    text</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;">dataset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emoji</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    x</span><span style="color:#475569;">: </span><span style="color:#1e293b;">center</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emojiSizeValue </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    y</span><span style="color:#475569;">: </span><span style="color:#1e293b;">center</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emojiSizeValue </span><span style="font-weight:bold;color:#0369a1;">/ </span><span style="font-weight:bold;color:#d97706;">2</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    width</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emojiSizeValue</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    height</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emojiSizeValue</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    fontSize</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">emojiSizeValue</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    id</span><span style="color:#475569;">: </span><span style="color:#0c4a6e;">Date</span><span style="color:#475569;">.</span><span style="color:#1e293b;">now</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;">random</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;">emojisOnCanvas</span><span style="color:#475569;">.</span><span style="color:#0284c7;">push</span><span style="color:#475569;">(</span><span style="color:#1e293b;">emoji</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;">redrawCanvas</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span></code></pre>
<p>Each emoji becomes an object with position, size and a unique ID. The emoji gets centered by subtracting half its size from the center coordinates. After adding it to the array, the canvas redraws to show the new emoji.</p>
<h2>
<a href="#drag-and-drop-interaction" aria-hidden="true" class="anchor" id="drag-and-drop-interaction"></a>Drag and drop interaction</h2>
<p>The drag system uses three mouse events: mousedown to select, mousemove to drag and mouseup to release:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">selectEmoji</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;">coordinates </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;">eventToCanvasCoordinates</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;">#</span><span style="color:#1e293b;">selectedEmoji </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;">emojiAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x</span><span style="color:#475569;">, </span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y</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;">selectedEmoji</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;">isDragging </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;">dragOffset </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">{
</span><span style="color:#0c4a6e;">      x</span><span style="color:#475569;">: </span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#selectedEmoji.</span><span style="color:#1e293b;">x</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">      y</span><span style="color:#475569;">: </span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#selectedEmoji.</span><span style="color:#1e293b;">y
</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;">canvasTarget</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:#475569;">"</span><span style="color:#0369a1;">grabbing</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;">moveSelectedEmoji</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="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;">isDragging </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;">selectedEmoji</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;">coordinates </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;">eventToCanvasCoordinates</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;">#</span><span style="color:#1e293b;">selectedEmoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </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;">dragOffset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x
</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;">selectedEmoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">coordinates</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </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;">dragOffset</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y
</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;">redrawCanvas</span><span style="color:#475569;">()
</span><span style="color:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">deselectEmoji</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;">isDragging </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;">#</span><span style="color:#1e293b;">selectedEmoji </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;">canvasTarget</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:#475569;">"</span><span style="color:#0369a1;">default</span><span style="color:#475569;">"
</span><span style="color:#475569;">}
</span></code></pre>
<p>The drag offset prevents emojis from jumping when you start dragging. Without it, the emoji would snap so its top-left corner sits under your mouse. With the offset, it maintains its relative position to your cursor. Smooth.</p>
<h2>
<a href="#helper-methods-for-canvas-operations" aria-hidden="true" class="anchor" id="helper-methods-for-canvas-operations"></a>Helper methods for canvas operations</h2>
<p>Several private methods handle the canvas coordinate system and rendering:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#1e293b;">get</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">canvasContext</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;">canvasTarget</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:#475569;">}
</span><span style="color:#0c4a6e;">
</span><span style="color:#1e293b;">get</span><span style="color:#0c4a6e;"> #</span><span style="color:#1e293b;">canvasCenter</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:#0c4a6e;">    x</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">canvasTarget</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;">2</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    y</span><span style="color:#475569;">: </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">canvasTarget</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;">2
</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;">eventToCanvasCoordinates</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;">rect </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">canvasTarget</span><span style="color:#475569;">.</span><span style="color:#1e293b;">getBoundingClientRect</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:#0c4a6e;">    x</span><span style="color:#475569;">: </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">clientX </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">rect</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">left</span><span style="color:#475569;">,
</span><span style="color:#0c4a6e;">    y</span><span style="color:#475569;">: </span><span style="color:#1e293b;">event</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">clientY </span><span style="font-weight:bold;color:#0369a1;">- </span><span style="color:#1e293b;">rect</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">top
</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;">emojiAtPosition</span><span style="color:#475569;">(</span><span style="color:#1e293b;">x</span><span style="color:#475569;">, </span><span style="color:#1e293b;">y</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="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;">emojisOnCanvas</span><span style="color:#475569;">].</span><span style="color:#0284c7;">reverse</span><span style="color:#475569;">().</span><span style="color:#1e293b;">find</span><span style="color:#475569;">(</span><span style="color:#1e293b;">emoji </span><span style="font-weight:bold;color:#dc2626;">=&gt;
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">x </span><span style="font-weight:bold;color:#0369a1;">&gt;= </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">x </span><span style="font-weight:bold;color:#0369a1;">&lt;= </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">width </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp;
</span><span style="color:#0c4a6e;">    </span><span style="color:#1e293b;">y </span><span style="font-weight:bold;color:#0369a1;">&gt;= </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">&amp;&amp; </span><span style="color:#1e293b;">y </span><span style="font-weight:bold;color:#0369a1;">&lt;= </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">height
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">)
</span><span style="color:#475569;">}
</span></code></pre>
<p>The coordinate conversion is important because mouse events give you screen coordinates, but canvas needs relative coordinates. The <code>getBoundingClientRect()</code> method provides the canvas position on screen.</p>
<p>Finding emojis at a position uses reverse order so top emojis get selected first. The hit detection checks if the click falls within the emoji’s bounding box.</p>
<h2>
<a href="#drawing-on-the-canvas" aria-hidden="true" class="anchor" id="drawing-on-the-canvas"></a>Drawing on the canvas</h2>
<p>The redraw method clears the canvas and renders all emojis:</p>
<pre lang="javascript" style="background-color:#f8fafc;"><code><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">redrawCanvas</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;">canvasContext</span><span style="color:#475569;">.</span><span style="color:#1e293b;">clearRect</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;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">canvasTarget</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">width</span><span style="color:#475569;">, </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">canvasTarget</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:#0c4a6e;">  </span><span style="color:#1e293b;">this</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">#</span><span style="color:#1e293b;">emojisOnCanvas</span><span style="color:#475569;">.</span><span style="color:#1e293b;">forEach</span><span style="color:#475569;">(</span><span style="color:#1e293b;">emoji </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;">canvasContext</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">font </span><span style="font-weight:bold;color:#0369a1;">= </span><span style="color:#475569;">`${</span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">fontSize</span><span style="color:#475569;">}</span><span style="color:#0369a1;">px Arial</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;">canvasContext</span><span style="color:#475569;">.</span><span style="color:#1e293b;">fillText</span><span style="color:#475569;">(</span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">text</span><span style="color:#475569;">, </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">x</span><span style="color:#475569;">, </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">y </span><span style="font-weight:bold;color:#0369a1;">+ </span><span style="color:#1e293b;">emoji</span><span style="color:#475569;">.</span><span style="color:#0c4a6e;">fontSize </span><span style="font-weight:bold;color:#0369a1;">* </span><span style="font-weight:bold;color:#d97706;">0.8</span><span style="color:#475569;">)
</span><span style="color:#0c4a6e;">  </span><span style="color:#475569;">})
</span><span style="color:#475569;">}
</span></code></pre>
<p>Canvas text positioning is tricky. The y-coordinate represents the baseline, not the top of the text. Adding <code>fontSize * 0.8</code> moves the text down so it appears where you expect it visually.</p>
<h2>
<a href="#canvas-performance-and-browser-support" aria-hidden="true" class="anchor" id="canvas-performance-and-browser-support"></a>Canvas performance and browser support</h2>
<p>Canvas has excellent browser support and performs well for this type of application. The clear-and-redraw approach might seem inefficient, but modern browsers optimize canvas operations heavily.</p>
<p>For emoji rendering, canvas handles Unicode characters beautifully. Emojis render as text, so they scale cleanly and look crisp at any size. The font size directly controls the emoji size.</p>
<p>One consideration: canvas is a raster format, so exported images have fixed resolution. For crisp results on high-DPI displays, you might want to scale up the canvas and export at higher resolution.</p>
<hr>
<p>This was too much fun to build and even more fun to actually use. The combination of canvas drawing, drag-and-drop interaction and image export creates something genuinely fun to use. See images above. 😊</p>
<p>Try building your own custom emoji and see what combinations you come up with! The possibilities are endless when you have a canvas and some creativity.</p>
]]></content>
    

    
  </entry>
  
  <entry>
    <id>https://railsdesigner.com/video-hover-preview-stimulus/?ref=rss</id>
    <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>

    
      <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>
    <id>https://railsdesigner.com/importmap-rails/?ref=rss</id>
    <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>

    
      <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>
    <id>https://railsdesigner.com/new-rails-designer/?ref=rss</id>
    <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>

    
      <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>
    <id>https://railsdesigner.com/timestamp-toggles/?ref=rss</id>
    <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>

    
      <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>
    <id>https://railsdesigner.com/recording-video-stimulus/?ref=rss</id>
    <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>

    
      <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>
    <id>https://railsdesigner.com/introducing-icons-gem/?ref=rss</id>
    <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>

    
      <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>
  
</feed>
