The project is in a healthy, maintained state
Hotwire Astra UI provides production-ready, accessible modal dialogs for Rails apps using Turbo Frames and Stimulus. Importmap-friendly, server-rendered, and framework-free, with stacked modals, CSS-driven animations, and full customization via CSS variables.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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

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

If 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-controller and target attributes
  • The CSS (either copy app/assets/stylesheets/hotwire/astra_ui/astra_ui.css or 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">&times;</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

  1. Trigger the Modal

Target the modal Turbo Frame:

<%= link_to "New Post", new_post_path, data: { turbo_frame: "astra_modal" } %>
  1. 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 provides aria-labelledby.
  • id: Turbo Frame id (default: "astra_modal").
  • theme_class: CSS class applied to the backdrop (default: "astra-default").
  • aria_label: used when title is 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/test

Or directly via Rake:

bundle exec rake test

License

The gem is available as open source under the terms of the MIT License.