Skip to content

Hotwire

The Hotwire adapter provides a Stimulus controller for bridging custom events and Turbo morph protection for Voidable components.

Terminal window
bundle add voidable-hotwire

The 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.

Terminal window
npm install @voidable/ui @voidable/theme @voidable/ui-hotwire

Register 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();

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:

Terminal window
rails new myapp --database=sqlite3
cd myapp

Step 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:

Terminal window
rm config/importmap.rb
rm bin/importmap

Remove the Propshaft assets initializer:

Terminal window
rm config/initializers/assets.rb

Remove 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.

Initialize a package.json in your Rails root and install Vite along with Hotwire and Voidable packages:

Terminal window
npm init -y
npm install vite @hotwired/turbo-rails @hotwired/stimulus
npm install @voidable/ui @voidable/theme @voidable/ui-hotwire

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:

  • root points at app/javascript where your source lives
  • build.outDir outputs to public/assets/ so Rails serves them as static files
  • assetsDir: "." prevents Vite from nesting output in an extra assets/ subdirectory
  • manifest: true generates a manifest.json for fingerprinted asset lookup
  • server.origin ensures HMR assets reference the Vite dev server in development

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:

Terminal window
rm app/javascript/controllers/index.js

Update 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 };

Note: If you’ll run voidable:install --asset_pipeline vite later (recommended), it overwrites application.html.erb with 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:

app/helpers/vite_helper.rb
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}"
end
end

Vite 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>

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";

Add build and dev scripts to your package.json:

{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}

In development you need both processes running:

Terminal window
bin/rails server -p 3200 # one terminal
npm run dev # another terminal

If 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.

Vite’s build output is generated — don’t commit it:

/public/assets

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 build

After adding the gem, run the install generator to scaffold a complete application layout with theme, importmap pins, and JS imports:

Terminal window
rails generate voidable:install
OptionValuesDefaultDescription
--layouttopbar, sidebar, switchingtopbarApplication layout style
--asset_pipelineimportmap, viteimportmapAsset pipeline integration
Terminal window
# Defaults: topbar layout, importmap pipeline
rails generate voidable:install
# Sidebar with fixed nav
rails generate voidable:install --layout sidebar
# Both layouts with a cookie-based toggle
rails generate voidable:install --layout switching
# For apps using Vite (see "Setting up Vite with Rails" above)
rails generate voidable:install --layout switching --asset_pipeline vite

The --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.

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.

For importmap apps (default):

FileDescription
config/initializers/voidable.rbVoidable initializer
config/importmap.rbPins for @voidable/ui and @voidable/ui-hotwire (appended)
app/javascript/application.jsAdds Voidable imports
app/views/layouts/application.html.erbApplication layout (topbar or sidebar)
app/views/layouts/_settings_menu.html.erbShared settings popover partial
app/assets/javascripts/voidable-layout.jsLayout JS (theme toggle, row clicks, dialog open/close, pagination)
app/assets/stylesheets/voidable-layout.cssLayout stylesheet
app/assets/stylesheets/voidable-devise.cssDevise 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):

FileDescription
config/initializers/voidable.rbVoidable initializer
app/javascript/application.jsAdds Voidable imports including CSS files
app/views/layouts/application.html.erbApplication layout (topbar or sidebar)
app/views/layouts/_settings_menu.html.erbShared settings popover partial
app/javascript/voidable-layout.jsLayout JS (theme toggle, row clicks, dialog open/close, pagination)
app/javascript/voidable-layout.cssLayout stylesheet
app/javascript/voidable-devise.cssDevise view styles
app/helpers/vite_helper.rbAsset path helper for Vite manifest

Same Devise/Pagy auto-configuration as the importmap path (when those gems are bundled at generator runtime).

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".

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" %>
LocalTypeDescription
popover_positionstringPopover direction: "top" (sidebar) or "bottom" (topbar)
layout_togglebooleanShow the layout switch button
layout_toggle_iconstringTabler icon name for the switch button
layout_toggle_labelstringButton label text

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>

VoidTurbo.start() prevents Turbo from clobbering client-managed attributes (open, checked, aria-expanded, etc.) on <void-*> elements during page morphs.

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.

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.

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.
  • 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 controller render :index instead). 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.

Attribute typeVoidable component
stringvoid-input
textvoid-textarea
integervoid-number-input
float, decimalvoid-number-input with step="any"
booleanvoid-switch (form) / void-badge (show, index)
datevoid-date-picker
datetimevoid-input with type="datetime-local"
timevoid-input with type="time"
referencesvoid-select
passwordvoid-input with type="password"
attachmentRails file_field
attachmentsRails file_field with multiple: true

Scaffolds use Pagy for pagination when the gem is bundled. Add it to your Gemfile alongside voidable-hotwire:

Terminal window
bundle add pagy
rails generate voidable:install # re-run to wire it up if you missed it the first time

The install generator detects Pagy and, when present:

  • Creates config/initializers/pagy.rb with require "pagy" and require "pagy/toolbox/paginators/method" (Pagy 9+ API).
  • Adds include Pagy::Method to ApplicationController.

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.

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.

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.

The gem includes pre-styled Devise views that match the Voidable design system.

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:

Terminal window
# 1. Add both gems
bundle add voidable-hotwire
bundle add devise
# 2. Set up Devise first
rails generate devise:install
rails generate devise User
rails db:migrate
# 3. Then run the Voidable installer — Devise views are auto-installed
rails generate voidable:install --layout switching

If you installed Voidable before Devise, run the Devise views generator separately afterward:

Terminal window
rails generate voidable:devise_views

This copies all Devise views into app/views/devise/ with Voidable components already wired up. The following views are included:

ViewDescription
sessions/newSign-in form with email and password fields
registrations/newSign-up form with email, password, and confirmation
registrations/editAccount settings with profile update and danger zone for account deletion
passwords/newForgot password form
passwords/editReset password form
confirmations/newResend confirmation instructions
unlocks/newResend unlock instructions
shared/_linksNavigation links between Devise views (sign in, sign up, forgot password)
shared/_error_messagesValidation error display using void-alert

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>

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>

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 %>
Devise conceptVoidable component
Form card wrappervoid-card
Text inputsvoid-input
Password inputsvoid-action-input with icon="eye"
Field labels & errorsvoid-field with label and error attributes
Submit buttonsvoid-button with color="success"
Remember mevoid-switch
Flash / validation errorsvoid-alert with color="error"
Account deletionvoid-dialog with danger zone section
Navigation linksvoid-button with variant="ghost"