Hotwire Astra UI π
Hotwire Astra UI is a production-ready, accessible modal system for Rails built with Hotwire (Turbo + Stimulus) and Importmap.
It enables fully server-rendered modals using Turbo Frames, with zero client-side configuration and no JavaScript frameworks.
If you are building Rails 7+ applications and want predictable, scalable modal behavior without SPA complexity, Astra UI is designed for you.
β¨ Features
π Zero-JavaScript Setup
Designed for Rails 7+, Hotwire, and Importmap. Install the gem, run the generator, and start rendering modals immediately.
No client state. No bundlers. No framework lock-in.
πͺ Recursive (Stacked) Modals
Open modals on top of modals using nested Turbo Frames.
Each modal layer:
- is independently closable
- maintains its own lifecycle
- works with normal Rails controllers
Infinite stacking, zero configuration.
ποΈ CSS-Driven Animations
Entry and exit transitions are powered by CSS variables, not JavaScript.
- Works with Tailwind or plain CSS
- Respects
prefers-reduced-motion - Fully overrideable
π Automatic Close on Turbo Success
Modals close automatically after successful Turbo form submissions.
No callbacks. No client logic. Just follow the Turbo lifecycle.
π¨ Headless UI Design
Astra UI ships with safe defaults, but nothing is opinionated.
- Override everything with CSS variables
- Compatible with design systems
- No forced colors, spacing, or typography
βΏ Accessibility-First
- Proper dialog semantics
- Keyboard navigation
- Focus management
- ESC and backdrop handling
π¦ Installation
Add the gem to your Gemfile:
gem "hotwire-astra-ui", "0.1.0"Install and run the generator:
bundle install
bin/rails generate hotwire_astra_ui:installThe installer registers the Stimulus controller for you. If your app does not have app/javascript/controllers/index.js, add this registration manually:
import HotwireAstraUiModalController from "hotwire_astra_ui/modal_controller"
application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)The installer adds a persistent Turbo Frame to your layout:
<%= turbo_frame_tag "astra_modal" %>This frame is used to render modal content.
π¦ npm Package (JS + CSS)
The JS-only shell is also published to npm:
npm install @digi-archive/hotwire-astra-uiIf you want to pin via Importmap (using the jspm CDN):
# config/importmap.rb
pin "@digi-archive/hotwire-astra-ui", to: "https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/app/javascript/hotwire-astra-ui.js"Register the controller:
import HotwireAstraUiModalController from "@digi-archive/hotwire-astra-ui"
application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)Load the CSS from the same CDN:
<link rel="stylesheet" href="https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/hotwire-astra-ui.css">π§© JS-Only (Importmap Pin)
If you only want the Stimulus controller (and will provide your own HTML/CSS), you can pin the JS directly with Importmap from the gem:
# config/importmap.rb
pin "hotwire_astra_ui/modal_controller", to: ""Register the controller:
import HotwireAstraUiModalController from "hotwire_astra_ui/modal_controller"
application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)You must also provide:
- The modal HTML structure with the correct
data-controllerand target attributes - The CSS (either copy
app/assets/stylesheets/hotwire/astra_ui/astra_ui.cssor reimplement it)
Example HTML structure (JS-only):
<div data-controller="hotwire-astra-ui-modal"
data-hotwire-astra-ui-modal-target="backdrop"
data-action="click->hotwire-astra-ui-modal#backdropClick"
class="astra-modal-backdrop astra-default">
<div class="astra-modal-container astra-modal-size-md"
data-hotwire-astra-ui-modal-target="container"
data-action="click->hotwire-astra-ui-modal#stopPropagation"
role="dialog"
aria-modal="true"
aria-label="Example Dialog"
tabindex="-1">
<div class="astra-modal-header">
<h2>Example Dialog</h2>
<button type="button" data-action="hotwire-astra-ui-modal#close" class="astra-close-btn" aria-label="Close dialog">×</button>
</div>
<div class="astra-modal-body">
Your content goes here.
</div>
</div>
</div>π¦ npm + Importmap (JS + CSS)
If you publish the npm package @digi-archive/hotwire-astra-ui, you can pin it via Importmap using the jspm CDN:
# config/importmap.rb
pin "@digi-archive/hotwire-astra-ui", to: "https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/app/javascript/hotwire-astra-ui.js"Register the controller:
import HotwireAstraUiModalController from "@digi-archive/hotwire-astra-ui"
application.register("hotwire-astra-ui-modal", HotwireAstraUiModalController)To load the CSS from npm, include the stylesheet from the same CDN:
<link rel="stylesheet" href="https://ga.jspm.io/npm:@digi-archive/hotwire-astra-ui@0.1.0/hotwire-astra-ui.css">π¨ Styles
Import the base stylesheet:
/*
*= require hotwire_astra_ui/astra_ui
*/All visual customization is done via CSS variables.
π Basic Usage
- Trigger the Modal
Target the modal Turbo Frame:
<%= link_to "New Post", new_post_path, data: { turbo_frame: "astra_modal" } %>- Render the Modal in a View Template
<%= astra_modal(title: "Create a New Post") do %>
<%= render partial: "form" %>
<% end %>
Thatβs it. No JavaScript. No client state.
π§© Advanced Usage
π§° Component Parameters
Hotwire::AstraUi::ModalComponent.new accepts:
-
title:string or nil. If present, renders the header and providesaria-labelledby. -
id:Turbo Frame id (default:"astra_modal"). -
theme_class:CSS class applied to the backdrop (default:"astra-default"). -
aria_label:used whentitleis nil (default:"Dialog"). -
size:one of:sm,:md,:lg,:xl(default::md). -
backdrop_close:boolean for backdrop click to close (default:true). -
return_focus:CSS selector to focus after close (default:nil).
πͺ Convenience Helper
Render from a view:
<%= astra_modal(title: "Create a New Post") do %>
<%= render partial: "form" %>
<% end %>π Stacked (Nested) Modals
Open a modal from inside another modal by targeting the next frame:
<%= link_to "Confirm Delete",
confirm_delete_path,
data: { turbo_frame: "astra_modal_next" } %>Template:
<%= astra_modal(title: "Are you sure?", id: "astra_modal_next") do %>
This action cannot be undone.
<% end %>Each modal layer remains isolated and predictable.
π Close Modals from the Server (Turbo Streams)
Close the modal directly from the server:
<%= close_astra_modal_tag %>Close a specific frame by id:
<%= close_astra_modal_tag(id: "astra_modal_next") %>Useful for:
- form submissions
- background jobs
- multi-step workflows
π§± Size Variants
Set a modal size with size::
render Hotwire::AstraUi::ModalComponent.new(title: "Large Modal", size: :lg)Available sizes: :sm, :md, :lg, :xl.
π§² Backdrop Click & Focus Return
Disable backdrop click to close:
render Hotwire::AstraUi::ModalComponent.new(title: "Locked", backdrop_close: false)Return focus to a specific element after close:
render Hotwire::AstraUi::ModalComponent.new(title: "Edit", return_focus: "#edit-button")π¨ Customization (CSS Variables)
Override the look without touching gem code:
:root {
--astra-modal-bg: #1e293b;
--astra-modal-radius: 0px;
--astra-backdrop-blur: 10px;
--astra-transition-duration: 500ms;
}Works with:
- Tailwind CSS
- Dark mode
- Design tokens
- Enterprise UI standards
π§ͺ Development
Run the dummy app locally:
cd test/dummy
bin/rails sβ Testing
Run the test suite:
bin/testOr directly via Rake:
bundle exec rake testLicense
The gem is available as open source under the terms of the MIT License.