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/[email protected]/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/[email protected]/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/[email protected]/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/[email protected]/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 ruby <%= 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.