Hotwire
The Hotwire adapter provides a Stimulus controller for bridging custom events and Turbo morph protection for Voidable components.
Install
Section titled “Install”Gem (Rails with importmaps)
Section titled “Gem (Rails with importmaps)”bundle add voidable-hotwireThe engine auto-registers importmap pins for @voidable/ui and @voidable/ui-hotwire, and includes the void_attrs view helper into ActionView.
Run rails generate voidable:install (see Install generator below) — it wires the Stimulus controller and Turbo morph protection automatically. No manual edit needed.
npm (Vite, esbuild, custom bundlers)
Section titled “npm (Vite, esbuild, custom bundlers)”npm install @voidable/ui @voidable/theme @voidable/ui-hotwireRegister VoidEventController with your Stimulus application and start Turbo morph protection:
import { Application } from '@hotwired/stimulus';import { VoidEventController } from '@voidable/ui-hotwire';import { VoidTurbo } from '@voidable/ui-hotwire';
const app = Application.start();app.register('void-event', VoidEventController);VoidTurbo.start();Setting up Vite with Rails
Section titled “Setting up Vite with Rails”This guide walks through replacing the default Rails asset pipeline (Propshaft + importmap) with plain Vite managed via npm. No additional gems are required — Vite builds assets to public/ and Rails serves them as static files.
Start with a standard Rails 8 app:
rails new myapp --database=sqlite3cd myappStep 1: Remove Propshaft, importmap, and stimulus-rails
Section titled “Step 1: Remove Propshaft, importmap, and stimulus-rails”Remove the gems that Vite replaces from your Gemfile:
# Remove these lines:gem "propshaft"gem "importmap-rails"gem "stimulus-rails"Run bundle install to update the lockfile.
Delete the importmap config and binary:
rm config/importmap.rbrm bin/importmapRemove the Propshaft assets initializer:
rm config/initializers/assets.rbRemove the config.assets.quiet = true line from config/environments/development.rb — it relies on Propshaft which is no longer present.
Remove the stale_when_importmap_changes line from app/controllers/application_controller.rb — it’s an importmap-rails feature.
Step 2: Create package.json
Section titled “Step 2: Create package.json”Initialize a package.json in your Rails root and install Vite along with Hotwire and Voidable packages:
npm init -ynpm install vite @hotwired/turbo-rails @hotwired/stimulusnpm install @voidable/ui @voidable/theme @voidable/ui-hotwireStep 3: Configure Vite
Section titled “Step 3: Configure Vite”Create vite.config.ts in your Rails root:
import { defineConfig } from "vite";
export default defineConfig({ root: "app/javascript", build: { outDir: "../../public/assets", assetsDir: ".", emptyOutDir: true, manifest: true, rollupOptions: { input: "app/javascript/application.js", }, }, server: { origin: "http://localhost:5173", },});Key points:
rootpoints atapp/javascriptwhere your source livesbuild.outDiroutputs topublic/assets/so Rails serves them as static filesassetsDir: "."prevents Vite from nesting output in an extraassets/subdirectorymanifest: truegenerates amanifest.jsonfor fingerprinted asset lookupserver.originensures HMR assets reference the Vite dev server in development
Step 4: Rewrite application.js
Section titled “Step 4: Rewrite application.js”Replace the importmap-style app/javascript/application.js with ES imports:
import "@hotwired/turbo-rails";import { Application } from "@hotwired/stimulus";import "@voidable/ui";import "@voidable/ui-hotwire";import { VoidEventController } from "@voidable/ui-hotwire";import { VoidTurbo } from "@voidable/ui-hotwire";
const app = Application.start();app.register("void-event", VoidEventController);VoidTurbo.start();This is the baseline the install generator builds on. When you run voidable:install --asset_pipeline vite (Step 10), it appends CSS imports and ensures VoidEventController and VoidTurbo are registered — it does not undo this step.
Delete the importmap controller loader since Vite handles module resolution:
rm app/javascript/controllers/index.jsUpdate app/javascript/controllers/application.js to remove the stimulus-loading import (it’s an importmap-only package):
import { Application } from "@hotwired/stimulus";
const application = Application.start();application.debug = false;window.Stimulus = application;
export { application };Step 5: Update the layout
Section titled “Step 5: Update the layout”Note: If you’ll run
voidable:install --asset_pipeline vitelater (recommended), it overwritesapplication.html.erbwith a complete Vite-ready layout. You can skip this step in that case.
Replace the Propshaft and importmap tags in app/views/layouts/application.html.erb:
<%# Includes all stylesheet files in app/assets/stylesheets %><%= stylesheet_link_tag :app, "data-turbo-track": "reload" %><%= javascript_importmap_tags %><link rel="stylesheet" href="/assets/application.css" data-turbo-track="reload"><script type="module" src="/assets/application.js" data-turbo-track="reload"></script>In production, reference the fingerprinted filenames from the Vite manifest. A minimal helper:
module ViteHelper def vite_asset_path(name) manifest_path = Rails.root.join("public/assets/.vite/manifest.json") if File.exist?(manifest_path) manifest = JSON.parse(File.read(manifest_path)) entry = manifest[name] return "/assets/#{entry['file']}" if entry
if name.end_with?(".css") manifest.each_value do |e| next unless e["css"] css_file = e["css"].find { |f| f.start_with?(File.basename(name, ".css")) } return "/assets/#{css_file}" if css_file end end end "/assets/#{name}" endendVite extracts CSS imported from JavaScript into separate files, but these don’t appear as top-level keys in the manifest — they’re nested under the importing JS entry’s css array. The fallback search handles this by scanning all entries for a matching CSS filename.
Then in your layout:
<link rel="stylesheet" href="<%= vite_asset_path('application.css') %>" data-turbo-track="reload"><script type="module" src="<%= vite_asset_path('application.js') %>" data-turbo-track="reload"></script>Step 6: Add CSS entry point
Section titled “Step 6: Add CSS entry point”Create app/javascript/application.css (or import your styles from application.js):
@import "@voidable/theme";Or import it from your JS entry point:
import "@voidable/theme";Step 7: Add scripts to package.json
Section titled “Step 7: Add scripts to package.json”Add build and dev scripts to your package.json:
{ "scripts": { "dev": "vite", "build": "vite build" }}Step 8: Run Rails and Vite together
Section titled “Step 8: Run Rails and Vite together”In development you need both processes running:
bin/rails server -p 3200 # one terminalnpm run dev # another terminalIf you want a single command, use a process manager of your choice (foreman, overmind, etc.) with a Procfile.dev — that’s a matter of preference, not a requirement.
Step 9: Add public/assets to .gitignore
Section titled “Step 9: Add public/assets to .gitignore”Vite’s build output is generated — don’t commit it:
/public/assetsProduction build
Section titled “Production build”Run npm run build before deploying. This generates fingerprinted files in public/assets/ with a .vite/manifest.json for asset lookup.
In your Dockerfile, add the Vite build step:
RUN npm install && npm run buildInstall generator
Section titled “Install generator”After adding the gem, run the install generator to scaffold a complete application layout with theme, importmap pins, and JS imports:
rails generate voidable:installOptions
Section titled “Options”| Option | Values | Default | Description |
|---|---|---|---|
--layout | topbar, sidebar, switching | topbar | Application layout style |
--asset_pipeline | importmap, vite | importmap | Asset pipeline integration |
# Defaults: topbar layout, importmap pipelinerails generate voidable:install
# Sidebar with fixed navrails generate voidable:install --layout sidebar
# Both layouts with a cookie-based togglerails generate voidable:install --layout switching
# For apps using Vite (see "Setting up Vite with Rails" above)rails generate voidable:install --layout switching --asset_pipeline viteThe --asset_pipeline vite flag changes the destination paths for generated assets from app/assets/stylesheets/ and app/assets/javascripts/ to app/javascript/, adds CSS @import statements to application.js, and creates a ViteHelper for production manifest lookup. It does not modify config/importmap.rb.
Before you run the generator
Section titled “Before you run the generator”If you’re using Devise, rails generate devise:install will warn that no root to: route exists. Define one before testing (any controller works — root to: "samples#index" for example). The gem’s “Dashboard” sidebar link falls back gracefully to / when no root route is defined, but having one makes it useful.
The install generator runs bin/rails active_storage:install automatically (added in 0.7.0), which queues an Active Storage migration alongside any scaffold migrations. Run bin/rails db:migrate after both the install generator and any scaffold generators to apply all pending migrations.
What it generates
Section titled “What it generates”For importmap apps (default):
| File | Description |
|---|---|
config/initializers/voidable.rb | Voidable initializer |
config/importmap.rb | Pins for @voidable/ui and @voidable/ui-hotwire (appended) |
app/javascript/application.js | Adds Voidable imports |
app/views/layouts/application.html.erb | Application layout (topbar or sidebar) |
app/views/layouts/_settings_menu.html.erb | Shared settings popover partial |
app/assets/javascripts/voidable-layout.js | Layout JS (theme toggle, row clicks, dialog open/close, pagination) |
app/assets/stylesheets/voidable-layout.css | Layout stylesheet |
app/assets/stylesheets/voidable-devise.css | Devise view styles |
If Devise is bundled when the generator runs, it auto-runs
voidable:devise_views and copies the styled Devise views into
app/views/devise/. If Pagy is bundled, it creates
config/initializers/pagy.rb and adds include Pagy::Method to
ApplicationController.
For Vite apps (--asset_pipeline vite):
| File | Description |
|---|---|
config/initializers/voidable.rb | Voidable initializer |
app/javascript/application.js | Adds Voidable imports including CSS files |
app/views/layouts/application.html.erb | Application layout (topbar or sidebar) |
app/views/layouts/_settings_menu.html.erb | Shared settings popover partial |
app/javascript/voidable-layout.js | Layout JS (theme toggle, row clicks, dialog open/close, pagination) |
app/javascript/voidable-layout.css | Layout stylesheet |
app/javascript/voidable-devise.css | Devise view styles |
app/helpers/vite_helper.rb | Asset path helper for Vite manifest |
Same Devise/Pagy auto-configuration as the importmap path (when those gems are bundled at generator runtime).
Switching mode
Section titled “Switching mode”When --layout switching is used, the generator additionally creates topbar and sidebar variants of the layout file (topbar.html.erb, sidebar.html.erb) and CSS files (voidable-layout-topbar.css, voidable-layout-sidebar.css), a SettingsController that toggles the layout cookie, a POST /toggle_layout route, and injects a layout :resolve_layout method into ApplicationController that reads the :layout cookie to pick the active layout, defaulting to "topbar".
Settings menu
Section titled “Settings menu”All layouts include a shared _settings_menu.html.erb partial rendered inside a void-popover. The gear icon opens a menu with:
- Profile link (when Devise is available and user is signed in)
- Dark mode toggle via
void-switch - Layout switch button (switching mode only)
The partial accepts locals to configure its behavior:
<%= render "layouts/settings_menu", popover_position: "bottom", layout_toggle: true, layout_toggle_icon: "layout-sidebar-left-collapse", layout_toggle_label: "Switch to sidebar" %>| Local | Type | Description |
|---|---|---|
popover_position | string | Popover direction: "top" (sidebar) or "bottom" (topbar) |
layout_toggle | boolean | Show the layout switch button |
layout_toggle_icon | string | Tabler icon name for the switch button |
layout_toggle_label | string | Button label text |
Theme toggle
Section titled “Theme toggle”The generated layouts include a theme toggle script that syncs void-switch state with localStorage and respects system preference on first load. The script listens for void-change events and persists the choice across Turbo navigations:
<script> (function() { const html = document.documentElement;
function applyTheme() { if (!localStorage.getItem('theme')) { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); } else { html.setAttribute('data-theme', localStorage.getItem('theme')); } }
function syncToggle() { const toggle = document.getElementById('theme-toggle'); if (toggle) { toggle.toggleAttribute('checked', html.getAttribute('data-theme') === 'dark'); } }
applyTheme(); syncToggle(); document.addEventListener('turbo:load', syncToggle);
document.addEventListener('void-change', (e) => { if (e.target.id !== 'theme-toggle') return; const next = e.detail.checked ? 'dark' : 'light'; html.setAttribute('data-theme', next); localStorage.setItem('theme', next); }); })();</script>Turbo morph protection
Section titled “Turbo morph protection”VoidTurbo.start() prevents Turbo from clobbering client-managed attributes (open, checked, aria-expanded, etc.) on <void-*> elements during page morphs.
Usage in ERB templates
Section titled “Usage in ERB templates”With the void_attrs helper (gem only):
void_attrs returns a Ruby hash ({ data: { controller: "void-event", ... } }) that you splat into a Rails tag helper:
<%= tag.void_button "Save", variant: "filled", **void_attrs("void-click", "form#submit") %>
<%= tag.void_input placeholder: "Email", **void_attrs("void-change", "search#filter") %>With manual data attributes:
<void-button variant="filled" data-controller="void-event" data-void-event-events-value="void-click" data-action="void-click->form#submit"> Save</void-button>
<void-input placeholder="Email" data-controller="void-event" data-void-event-events-value="void-change" data-action="void-change->search#filter"></void-input>The VoidEventController listens for the specified custom events on the element and re-dispatches them as Stimulus-compatible events so they can be routed via data-action.
Scaffolds
Section titled “Scaffolds”The gem overrides Rails’ default scaffold templates so rails generate scaffold produces Voidable-styled views automatically. The engine registers templates via config.generators.templates.unshift, requiring no extra configuration.
Generated views
Section titled “Generated views”The gem scaffolds use an index-with-modal architecture: the show,
edit, and new actions all render the index template with the
appropriate modal open. Direct URL navigation to a resource lands on
the same page as clicking a row.
- Index (
index.html.erb) — page header with title, subtitle, and “New” button; hoverable table whose rows are clickable (class="row-clickable" data-href="...") and open the show modal; pagination via<void-pagination>when Pagy is configured; empty state when no records exist. Below the table, three conditional modals:- Show modal — opened by clicking a row or navigating to
/resources/:id. Renders a<dl class="detail-grid">of the record’s attributes plus Edit / Delete action buttons. - Edit modal (
@edit_mode) — opened by clicking Edit inside the show modal or navigating to/resources/:id/edit. Renders the form partial. - New modal (
@new_mode) — opened by clicking the “New” header button or navigating to/resources/new. Renders the form partial.
- Show modal — opened by clicking a row or navigating to
- Form partial (
_form.html.erb) —<void-field>per attribute, full-width divider above the action row, Submit + Back buttons. No<void-card>wrapper — that’s externally applied only when the form is rendered standalone, so the dialog body doesn’t show a nested border. - Turbo partial (
_<resource>.html.erb) — Rails-conventional partial used by Turbo Stream responses. - Stub views (
show.html.erb,edit.html.erb,new.html.erb) — shipped as empty stubs with an HTML comment explaining they are never rendered (the controllerrender :indexinstead). Safe to delete.
The scaffold controller’s show, edit, and new actions each load
pagy data and call render :index. update and create failures
also fall back to render :index with the matching mode flag so form
errors appear in the modal.
Component mapping by type
Section titled “Component mapping by type”| Attribute type | Voidable component |
|---|---|
string | void-input |
text | void-textarea |
integer | void-number-input |
float, decimal | void-number-input with step="any" |
boolean | void-switch (form) / void-badge (show, index) |
date | void-date-picker |
datetime | void-input with type="datetime-local" |
time | void-input with type="time" |
references | void-select |
password | void-input with type="password" |
attachment | Rails file_field |
attachments | Rails file_field with multiple: true |
Pagination
Section titled “Pagination”Scaffolds use Pagy for pagination
when the gem is bundled. Add it to your Gemfile alongside
voidable-hotwire:
bundle add pagyrails generate voidable:install # re-run to wire it up if you missed it the first timeThe install generator detects Pagy and, when present:
- Creates
config/initializers/pagy.rbwithrequire "pagy"andrequire "pagy/toolbox/paginators/method"(Pagy 9+ API). - Adds
include Pagy::MethodtoApplicationController.
If Pagy is not bundled when the generator runs, it prints a
recommendation and skips the configuration. Re-run the generator
after bundle add pagy to complete the wiring.
In scaffolded controllers, the index, show, edit, and new
actions all call:
@pagy, @<plural> = pagy(<Model>.all)The index template renders <void-pagination> at the bottom of the
table when @pagy.pages > 1. voidable-layout.js listens for
void-change events on <void-pagination> and translates clicks to
?page=N URL changes via Turbo.visit.
Delete confirmation
Section titled “Delete confirmation”The Delete button in the show modal opens a secondary <void-dialog>
for confirmation. The gem’s layout JS handles open/close via data
attributes — no inline onclick handlers are needed:
<void-button data-open-dialog="resource-delete-dialog">Delete</void-button>
<void-dialog id="resource-delete-dialog" heading="Delete order" size="sm"> <p class="danger-zone-description">Are you sure? This action cannot be undone.</p> <div class="action-bar"> <void-button variant="ghost" size="sm" data-close-dialog="resource-delete-dialog">Cancel</void-button> <void-button color="error" size="sm" ><a href="<%= url_for(@order) %>" data-turbo-method="delete">Delete</a></void-button> </div></void-dialog>voidable-layout.js listens for clicks on [data-open-dialog] and
[data-close-dialog] and toggles the targeted dialog’s open
attribute. The same handler is used by the Devise account-deletion
flow.
Back navigation
Section titled “Back navigation”Closing a resource modal navigates back to the index. The layout JS
remembers the user’s index URL (with pagination/filter query string)
in sessionStorage whenever a row is clicked, keyed by the
destination path. When <void-dialog data-close-href="..."> dispatches
void-close, the handler navigates to the saved URL — falling back
to the dialog’s data-close-href for direct URL navigations where
no saved state exists.
While a resource modal is open, the index template emits
<meta name="turbo-cache-control" content="no-cache">. Light-DOM
custom-element children are captured verbatim in Turbo’s snapshot
cache, and restoring them would duplicate the dialog body — opting
out of caching avoids this.
Devise
Section titled “Devise”The gem includes pre-styled Devise views that match the Voidable design system.
Setup order
Section titled “Setup order”The voidable:install generator auto-runs voidable:devise_views only if Devise is already loaded at generator execution time. If you add Devise to your app, the recommended order is:
# 1. Add both gemsbundle add voidable-hotwirebundle add devise
# 2. Set up Devise firstrails generate devise:installrails generate devise Userrails db:migrate
# 3. Then run the Voidable installer — Devise views are auto-installedrails generate voidable:install --layout switchingIf you installed Voidable before Devise, run the Devise views generator separately afterward:
rails generate voidable:devise_viewsThis copies all Devise views into app/views/devise/ with Voidable components already wired up. The following views are included:
| View | Description |
|---|---|
sessions/new | Sign-in form with email and password fields |
registrations/new | Sign-up form with email, password, and confirmation |
registrations/edit | Account settings with profile update and danger zone for account deletion |
passwords/new | Forgot password form |
passwords/edit | Reset password form |
confirmations/new | Resend confirmation instructions |
unlocks/new | Resend unlock instructions |
shared/_links | Navigation links between Devise views (sign in, sign up, forgot password) |
shared/_error_messages | Validation error display using void-alert |
Sign-in form
Section titled “Sign-in form”A typical sign-in view using Voidable components:
<div style="max-width: 28rem; margin: 0 auto; padding: 2rem;"> <void-card> <div style="padding: var(--void-space-6);"> <h1 style="margin: 0 0 var(--void-space-5) 0; font-family: var(--void-font-sans); font-size: var(--void-text-xl); font-weight: var(--void-weight-bold); color: var(--void-color-text);">Sign in</h1>
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { style: "display: flex; flex-direction: column; gap: var(--void-space-4);" }) do |f| %> <%= render "devise/shared/error_messages", resource: resource %>
<void-field label="Email"> <void-input type="email" name="<%= resource_name %>[email]" value="<%= resource.email %>" autofocus autocomplete="email"></void-input> </void-field>
<void-field label="Password"> <void-action-input type="password" icon="eye" action-label="Toggle visibility" name="<%= resource_name %>[password]" autocomplete="current-password"></void-action-input> </void-field>
<div style="padding-top: var(--void-space-2);"> <void-button type="submit" color="success" style="width: 100%;">Sign in</void-button> </div> <% end %> </div> </void-card></div>Password visibility toggle
Section titled “Password visibility toggle”void-action-input provides a built-in action button for password reveal. The generated layouts include a global event listener that handles the toggle automatically:
<script> document.addEventListener('void-action', (e) => { const el = e.target; if (el.tagName === 'VOID-ACTION-INPUT' && (el.type === 'password' || el.type === 'text')) { const visible = el.type === 'text'; el.type = visible ? 'password' : 'text'; el.icon = visible ? 'eye' : 'eye-off'; } });</script>Error messages
Section titled “Error messages”The shared error messages partial uses void-alert:
<% if resource.errors.any? %> <void-alert color="error" data-turbo-temporary> <strong><%= pluralize(resource.errors.count, "error") %> prohibited this operation:</strong> <ul> <% resource.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </void-alert><% end %>Components used
Section titled “Components used”| Devise concept | Voidable component |
|---|---|
| Form card wrapper | void-card |
| Text inputs | void-input |
| Password inputs | void-action-input with icon="eye" |
| Field labels & errors | void-field with label and error attributes |
| Submit buttons | void-button with color="success" |
| Remember me | void-switch |
| Flash / validation errors | void-alert with color="error" |
| Account deletion | void-dialog with danger zone section |
| Navigation links | void-button with variant="ghost" |