๐ Table of contents
- Why modal_stack?
- Features
- Compatibility
- Installation
- Quick start
- Configuration
- Usage
- Reference
- CSS presets & theming
- Asset pipelines
- Accessibility
- Development
- Releasing
- Contributing
- License
๐ค Why modal_stack?
The Hotwire ecosystem has a few "single modal" libraries, but the moment your
app needs to open a modal from inside another modal โ picking a customer
while creating an invoice, running a 4-step wizard inside a drawer, or
browser-back-ing through nested confirmation steps โ they break down.
ultimate_turbo_modal |
DIY Stimulus | modal_stack |
|
|---|---|---|---|
| 1 modal + history | โ | โ | โ |
Native <dialog> + focus trap |
โ | โ | โ |
| Drawers (left/right/top/bottom) | partial | โ | โ |
| Bottom sheets | โ | โ | โ |
| Stack of N layers | โ | โ | โ |
| Wizard step-by-step inside a layer | โ | โ | โ |
| Browser back pops one layer at a time | โ | โ | โ |
| Imperative Turbo Stream actions | partial | โ | โ |
| Custom width/height per layer | โ | โ | โ |
dismissible: false (locked layers) |
โ | โ | โ |
| Tailwind / Bootstrap / vanilla CSS presets | โ | โ | โ |
| Capybara matchers shipped | โ | โ | โ |
โจ Features
- ๐ช Stack of N layers โ push modals on top of modals; the underlying ones become
inertautomatically. - ๐ช Native
<dialog>โ focus trap, ESC, accessible roles for free. - ๐ Deep-linking โ the top of the stack lives in
window.location. Bookmark it, share it, refresh it. - โฉ๏ธ Browser back = pop โ one history entry per layer;
cmd+โdoes what users expect. - ๐ฎ Imperative Turbo Stream actions โ
turbo_stream.modal_push / modal_pop / modal_replace / modal_close_allfrom anywhere. - ๐จ Three CSS presets โ Tailwind, Bootstrap, vanilla. All driven by
--modal-stack-*CSS variables for easy retheming. - ๐ช Four variants โ
modal,drawer(with side),bottom_sheet,confirmation. - ๐ Sizes & custom dimensions โ
:sm/:md/:lg/:xl, or passwidth:/height:strings ("42rem","min(90vw, 56rem)"). - ๐ Dismissible flag โ
dismissible: falsefor confirmations users must answer. - โฟ
prefers-reduced-motionโ animations collapse to 1ms when the OS asks. - ๐งช Capybara matchers โ
within_modal,have_modal_open,have_modal_stack(depth: 2),close_modal,close_all_modals. - โก Three asset pipelines โ Importmap (default), jsbundling, Sprockets.
- ๐งฑ Engine-based โ zero monkey-patching, pure Rails Engine + Stimulus + Turbo.
๐ง Compatibility
Tested on every combination of Ruby and Rails listed below via the Appraisal gem:
| Rails 7.2 | Rails 8.0 | Rails 8.1.3 | Rails 8.1.3 + Sprockets | |
|---|---|---|---|---|
| Ruby 3.2 | โ | โ | โ | โ |
| Ruby 3.3 | โ | โ | โ | โ |
| Ruby 3.4 | โ | โ | โ | โ |
| Ruby 3.5 | โ | โ | โ | โ |
| Ruby 4.0 | โ | โ | โ | โ |
Requirements: Ruby โฅ 3.2, Rails โฅ 7.2 (
railties >= 7.2),turbo-rails >= 2.0, Stimulus โฅ 3.0.
๐ฆ Installation
Add to your Gemfile:
gem "modal_stack"
Then run:
$ bundle install
$ bin/rails g modal_stack:install
The generator autodetects your asset pipeline. You can force it:
$ bin/rails g modal_stack:install --mode=importmap # default for new Rails apps
$ bin/rails g modal_stack:install --mode=jsbundling # esbuild, vite, bun
$ bin/rails g modal_stack:install --mode=sprockets # legacy apps
Pick the CSS preset that matches your stack:
$ bin/rails g modal_stack:install --css-provider=tailwind # default
$ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
$ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
$ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
What the generator does
- ๐ creates
config/initializers/modal_stack.rb - ๐ pins (Importmap) or installs (jsbundling)
@hotwired/stimulusandmodal_stack - ๐จ wires the chosen CSS preset into the asset pipeline
- ๐ injects
<%= modal_stack_stylesheet_link_tag %>and<%= modal_stack_dialog_tag %>intoapp/views/layouts/application.html.erb - ๐ appends the
installModalStack(application)call to your Stimulus entrypoint
In your JS entrypoint (e.g. app/javascript/controllers/application.js):
import { Application } from "@hotwired/stimulus"
import { install as installModalStack } from "modal_stack"
const application = Application.start()
installModalStack(application)
๐ Quick start
<%# app/views/projects/index.html.erb %>
<%= modal_link_to "Edit", edit_project_path(@project) %>
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
modal_stack_layout
# ...
end
<%# app/views/projects/edit.html.erb %>
<%= modal_stack_container do %>
<%= form_with model: @project do |f| %>
<%= f.text_field :name %>
<%= f.submit %>
<% end %>
<% end %>
That's it. Click the link โ the form opens in a modal, the URL updates to
/projects/42/edit, browser back closes the modal, refresh re-opens it
right where it was.
โ๏ธ Configuration
Everything lives in config/initializers/modal_stack.rb:
ModalStack.configure do |config|
# โโโ Presentation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
config.css_provider = :tailwind # :tailwind | :bootstrap | :vanilla | :none
config.default_variant = :modal # :modal | :drawer | :bottom_sheet | :confirmation
config.default_size = :md # :sm | :md | :lg | :xl
config.default_dismissible = true # ESC + backdrop click close the layer
# โโโ Behavior โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
config.max_depth = 5 # hard cap on nested layers (nil to disable)
config.max_depth_strategy = :warn # :warn | :raise | :silent
config.respect_reduced_motion = true # honor prefers-reduced-motion
config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
# โโโ Wiring (rarely changed) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
config.dialog_id = "modal-stack-root"
config.stack_root_data_attribute = "modal-stack"
config.request_header = "X-Modal-Stack-Request"
config.assets_mode = :auto # :importmap | :jsbundling | :sprockets | :auto
# โโโ i18n โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
config.i18n_scope = "modal_stack"
end
๐ก
config.initializer_versionis stamped automatically by the generator. When you upgrademodal_stack, a boot-time warning tells you if the installed gem ships a newer initializer template than what you have. Setconfig.silence_initializer_warning = trueto mute it.
๐ฏ Usage
Opening a modal from a link
<%= modal_link_to "Edit", edit_project_path(@project) %>
<%= modal_link_to "Details", project_path(@project), as: :drawer, side: :right %>
<%= modal_link_to "Settings", settings_path, as: :bottom_sheet %>
<%= modal_link_to "Confirm", confirm_path, dismissible: false %>
modal_link_to accepts the same arguments as Rails' link_to, plus:
| Option | Type | Description |
|---|---|---|
as: |
Symbol | Variant โ :modal (default), :drawer, :bottom_sheet, :confirmation |
side: |
Symbol | Drawer side โ :left, :right (default), :top, :bottom |
size: |
Symbol | :sm, :md, :lg, :xl |
width: |
String | CSS length (e.g. "42rem", "min(90vw, 56rem)") |
height: |
String | CSS length |
dismissible: |
Boolean | When false, ESC and backdrop click are ignored |
Hotwire Native fallback: when the request comes from a Hotwire Native shell (matched on User-Agent),
modal_link_toquietly degrades to plainlink_toso the platform's native navigation handles it.
The modal layout
The gem ships a minimal modal layout (app/views/layouts/modal.html.erb)
that just yields. Each panel view is responsible for wrapping itself in
modal_stack_container, which lets every action pick its own size/variant
options at the call site:
<%# app/views/projects/edit.html.erb %>
<%= modal_stack_container size: :lg do %>
<h2>Edit project</h2>
<%= render "form", project: @project %>
<% end %>
modal_stack_container accepts size:, variant:, side:, width:,
height:, dismissible:, and an html: { class:, data:, ... } Hash for
extra attributes on the wrapping <div>.
Stack-aware controllers
modal_stack_layout switches the controller's layout to modal only
when the request was issued by the modal_stack JS runtime (signaled by the
X-Modal-Stack-Request header). Direct visits / refreshes still get the
regular application layout, so deep-links keep rendering full pages.
class ProjectsController < ApplicationController
modal_stack_layout # all actions
modal_stack_layout except: [:index] # the standard Rails-style filter works
modal_stack_layout fallback: "admin" # fallback layout for non-stack requests
end
render_modal is a shortcut for re-rendering inside the modal layout โ
typically after a validation failure:
def update
if @project.update(project_params)
redirect_to @project
else
render_modal :edit, status: :unprocessable_entity
end
end
modal_stack_request? is exposed as both a controller method and a view
helper for branching on stack-vs-page requests.
Turbo Stream actions
For programmatic stack manipulation from anywhere a Turbo Stream lands (create/update/destroy, ActionCable broadcast, custom controller action):
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.modal_push(
template: "items/new",
variant: :drawer,
side: :right,
size: :lg
)
end
end
Available actions:
| Action | Effect |
|---|---|
turbo_stream.modal_push(content, **opts) |
Push a new layer on top of the stack. Same content options as Turbo's standard streams (partial:/template:/locals:/raw block). |
turbo_stream.modal_pop |
Pop the top layer. |
turbo_stream.modal_replace(content, **opts) |
Morph the top layer in place. Defaults to history: :replace; pass history: :push for wizard-step semantics where browser-back returns to the previous step. |
turbo_stream.modal_close_all |
Tear down the entire stack. |
Variants, sizes, custom dimensions
Four variants:
:modal(default) โ centered overlay panel:drawerโ slides in from a side; passside: :left | :right | :top | :bottom:bottom_sheetโ full-width sheet that slides up from the bottom (mobile-first):confirmationโ typically combined withdismissible: falsefor "are you sure?" flows
Sizes via the size: keyword pick from :sm, :md, :lg, :xl. The
preset CSS maps each to a max-width (and max-height for bottom sheets).
Need a one-off dimension? Pass width: and/or height: as CSS length
strings โ they're applied as inline styles, taking precedence over size::
<%= modal_link_to "Print preview", preview_path,
width: "min(90vw, 56rem)", height: "85vh" %>
Wizards & multi-step flows
For step-by-step flows inside a single layer (onboarding, multi-step forms),
combine modal_push (for the initial open) with modal_replace carrying
history: :push between steps. Each step gets its own URL and a real
history entry, so browser-back returns to the previous step (not the page
behind the wizard):
class WizardController < ApplicationController
modal_stack_layout
def step_2
respond_to do |format|
format.html # full-page render for deep-links
format.turbo_stream do
render turbo_stream: turbo_stream.modal_replace(
template: "wizard/step_2",
history: :push,
url: wizard_step_2_path
)
end
end
end
end
Stack depth & inertness
When a layer is pushed on top of another, the bottom layer automatically
gets the inert HTML attribute, so screen-readers and pointer/keyboard
events skip it entirely. When the top layer is popped, inert is removed
from what becomes the new top.
The <dialog> itself is opened on first push, closed on last pop. Page
scroll is locked while any layer is open (<body data-modal-stack-locked>)
so the page beneath doesn't scroll under your finger on touch devices.
max_depth (default 5) is a hard ceiling on the number of stacked layers,
on the assumption that going past it usually means you have a state-machine
bug. The behaviour is controlled by config.max_depth_strategy:
| Strategy | Behaviour |
|---|---|
:warn |
(default) The push is dropped and console.warn logs a message. |
:raise |
The JS runtime throws ModalStackDepthError (caught by the stream-action error boundary, see below). |
:silent |
The push is dropped without logging. |
Set config.max_depth = nil to disable the cap entirely.
๐ Reference
ModalStack.configure
ModalStack.configure { |config| ... }
ModalStack.configuration # reader, memoized
ModalStack.reset_configuration! # test-fixture helper
| Attribute | Type | Default | Description |
|---|---|---|---|
css_provider |
Symbol | :tailwind |
One of :tailwind, :bootstrap, :vanilla, :none. Determines which stylesheet modal_stack_stylesheet_link_tag resolves to. Validated. |
assets_mode |
Symbol | :auto |
One of :importmap, :jsbundling, :sprockets, :auto. Used by the generator. Validated. |
default_variant |
Symbol | :modal |
:modal, :drawer, :bottom_sheet, or :confirmation. Validated. |
default_size |
Symbol | :md |
:sm, :md, :lg, :xl. Validated. |
default_dismissible |
Boolean | true |
Default for dismissible: when omitted. |
default_classes |
Hash | { ... } |
Hash of extra CSS class strings keyed by :modal_panel, :drawer_panel, :bottom_sheet_panel, :confirmation_panel. Useful for adding utility classes on top of the chosen preset. |
max_depth |
Integer | 5 |
Hard cap on stack depth. Coerced from strings; set to nil to disable. Validated. |
max_depth_strategy |
Symbol | :warn |
One of :warn, :raise, :silent. See Stack depth & inertness. Validated. |
request_header |
String | "X-Modal-Stack-Request" |
HTTP header used by the JS runtime to signal stack-originated fetches. Read by modal_stack_request?. |
dialog_id |
String | "modal-stack-root" |
The id of the singleton <dialog>. Override only on name collision. |
stack_root_data_attribute |
String | "modal-stack" |
The Stimulus data-controller value attached to the <dialog>. |
respect_reduced_motion |
Boolean | true |
When the OS reports prefers-reduced-motion: reduce, presets collapse transitions to 1ms. |
replace_turbo_confirm |
Boolean | false |
When true, replaces data-turbo-confirm window.confirm with a stack-rendered confirmation layer. |
i18n_scope |
String | "modal_stack" |
I18n scope for user-facing strings (close button, swipe-down hint, โฆ). |
initializer_version |
String | nil (set by generator) |
Stamped by the install generator; used to warn when an older template is in use after a gem upgrade. |
silence_initializer_warning |
Boolean | false |
Mutes the boot-time warning when the stamped version differs from the gem's. |
View helpers
Injected into ActionView::Base by the engine โ available in every view.
| Helper | Description |
|---|---|
modal_link_to(name, options, html_options) |
Renders a link_to wired to push a layer when clicked. Accepts the modal options (as:, side:, size:, width:, height:, dismissible:) on top of standard link_to arguments. Falls back to plain link_to for Hotwire Native requests. |
modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... } |
Wraps a panel view with the markup the JS runtime expects. Renders a <div> carrying the size/variant/dismissible/dimension data attributes. |
modal_stack_stylesheet_link_tag(**options) |
Emits <link rel="stylesheet"> for the configured preset (modal_stack/tailwind.css, etc.). Returns an empty SafeBuffer when css_provider = :none. |
modal_stack_dialog_tag(**html_options) |
Emits the singleton <dialog id="modal-stack-root" data-controller="modal-stack">. Drop just before </body>. |
modal_stack_javascript_tag |
Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
Controller extensions
Mixed into ActionController::Base by the engine.
| Method | Description |
|---|---|
modal_stack_layout(fallback: nil, **conditions) (class macro) |
Switches the layout to "modal" for stack-originated requests. fallback: accepts a layout name, nil, or a callable. **conditions forwards only: / except: to Rails' layout directive. |
render_modal(template_or_options = nil, **options) |
Convenience for re-rendering inside the modal layout โ useful after validation failures. |
modal_stack_request? (also a view helper) |
true when the request carries the X-Modal-Stack-Request header. |
Turbo Stream actions reference
Mixed into Turbo::Streams::TagBuilder. All target the singleton dialog
(ModalStack::TARGET_ID = "modal-stack-root") and accept the same content
options as Turbo's built-in stream actions (partial:, template:,
locals:, raw HTML block, โฆ).
| Action | Options |
|---|---|
modal_push(content = nil, **opts, &block) |
variant:, dismissible:, url:, side:, size:, width:, height:, plus any rendering options |
modal_pop |
โ |
modal_replace(content = nil, **opts, &block) |
All modal_push options plus history: (:replace (default) or :push) and layer_id: |
modal_close_all |
โ |
history: :push raises ArgumentError if given any value other than
:push or :replace.
Layer DOM contract
Each pushed layer is a <div> inside the dialog with:
<div data-modal-stack-target="layer"
data-layer-id="ms-โฆ"
data-depth="2"
data-variant="drawer"
data-side="right"
data-dismissible="true"
data-modal-stack-size="lg"
data-modal-stack-width="42rem" style="width: 42rem;">
<!-- panel content -->
</div>
Underlying layers receive inert. A layer being unmounted gets
data-leaving="" for the duration of the exit transition (capped at
600ms even if the host CSS forgets to define one).
Stimulus controllers
Both controllers are registered via installModalStack(application).
| Identifier | Role |
|---|---|
modal-stack |
Bound to the singleton <dialog>. Wires popstate / cancel / backdrop-click listeners, registers the Turbo.StreamActions, hosts the Orchestrator. |
modal-stack-link |
Attached to elements rendered by modal_link_to. On click, finds the modal-stack controller and calls push({ url, variant, โฆ }) from the element's data attributes. |
JS runtime
The package exports a small functional core + a browser adapter:
import {
// pure reducer โ no IO, no DOM
createStack, push, pop, replaceTop, closeAll, handlePopstate,
snapshot, restore, topLayer, VARIANTS, ModalStackDepthError,
// orchestrator + browser runtime
Orchestrator, BrowserRuntime,
FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
} from "modal_stack"
import { install } from "modal_stack/install"
install(application) registers both Stimulus controllers โ that's the
entry point your application.js calls. The reducer is
side-effect-free and 100% covered; the browser adapter is the only
file that touches <dialog>, history, fetch, and sessionStorage.
The reducer's command type vocabulary (mountLayer, morphTopLayer,
unmountTopLayer, unmountAllLayers, showDialog, closeDialog,
lockScroll, unlockScroll, inertLayer, pushHistory,
replaceHistory, historyBack, rebuildFromSnapshot, persistSnapshot,
clearSnapshot) forms the contract between state.js and any runtime โ
swap in a custom adapter (e.g. for Hotwire Native) by implementing one
method per command name.
Custom events
The <dialog> emits two CustomEvents that bubble to document:
| Event | detail |
Fired when |
|---|---|---|
modal_stack:ready |
{ stackId } |
The Stimulus controller has connected and the orchestrator is ready. |
modal_stack:error |
{ action, error } |
A Turbo Stream action (modal_push/modal_pop/modal_replace/modal_close_all) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
document.addEventListener("modal_stack:error", (event) => {
const { action, error } = event.detail;
showFlash(`Modal action ${action} failed: ${error.message}`);
});
Scrollbar-width compensation
When the first layer is pushed, BrowserRuntime#lockScroll measures
window.innerWidth - documentElement.clientWidth and writes the result
to --modal-stack-scrollbar-width on <html>. The shipped CSS presets
already consume the variable (padding-right: var(--modal-stack-scrollbar-width, 0))
so fixed elements don't jump rightward on lock. If you maintain custom
CSS, compose your fixed-position rules against the same variable.
Capybara helpers
For system specs, opt in by requiring the RSpec entrypoint:
# spec/rails_helper.rb
require "modal_stack/capybara/rspec"
This auto-includes the matchers in type: :system and type: :feature
specs. For Minitest, require "modal_stack/capybara/minitest".
| Helper / matcher | Description |
|---|---|
within_modal(depth: nil) { ... } |
Scopes Capybara matchers to a layer. Defaults to the topmost; depth: 1 is the bottom. Raises Capybara::ElementNotFound when no such layer exists. |
have_modal_open |
Matcher: passes when the dialog has [open]. |
have_no_modal_open |
Negation. |
have_modal_stack(depth: nil) |
Matcher: asserts the live (non-leaving) layer count. |
have_no_modal_stack |
Negation. |
close_modal |
Sends ESC to the dialog. Honors dismissible: false (the layer stays). |
close_all_modals(max: 16) |
Pops every layer by sending ESC repeatedly. |
modal_stack_depth |
Reads the current depth from the live DOM. |
Generator
$ bin/rails g modal_stack:install [flags]
| Flag | Type | Default | Values |
|---|---|---|---|
--mode |
String | auto |
auto, importmap, jsbundling, sprockets |
--css-provider |
String | tailwind |
tailwind, bootstrap, vanilla, none |
--skip-layout |
Boolean | false |
When set, doesn't inject the stylesheet/dialog helpers into application.html.erb |
--skip-js |
Boolean | false |
When set, skips the Importmap pin / package install / Stimulus install wiring |
--skip-initializer |
Boolean | false |
When set, doesn't generate config/initializers/modal_stack.rb |
--mode=auto detection order:
config/importmap.rbpresent โimportmap- Sprockets manifest present and no
config/importmap.rband nopackage.jsonโsprockets package.jsonpresent โjsbundling- fallback โ
importmap
All append operations are idempotent โ running the generator twice is safe.
๐จ CSS presets & theming
Three opinionated stylesheets ship with the gem. Pick one with
config.css_provider:
| Preset | File | Best for |
|---|---|---|
:tailwind |
app/assets/stylesheets/modal_stack/tailwind.css |
Tailwind apps โ uses Tailwind tokens by default but overridable |
:bootstrap |
app/assets/stylesheets/modal_stack/bootstrap.css |
Picks up Bootstrap 5 CSS variables |
:vanilla |
app/assets/stylesheets/modal_stack/vanilla.css |
Framework-free, neutral defaults |
:none |
โ | Bring your own CSS |
All three presets are driven by the same --modal-stack-* CSS variables.
Override on :root to retheme without touching the gem:
:root {
--modal-stack-radius: 16px;
--modal-stack-bg: #18181b;
--modal-stack-fg: #f4f4f5;
--modal-stack-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.6);
--modal-stack-backdrop: rgba(0, 0, 0, 0.7);
--modal-stack-duration: 180ms;
}
Variants and sizes are addressed via data attributes on the panel:
[data-variant="drawer"][data-side="right"],
[data-modal-stack-size="lg"], etc.
โก Asset pipelines
modal_stack adapts to whichever pipeline you use โ the generator picks
the right setup automatically.
โโ Importmap (Rails 7+ default) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ config/importmap.rb โ
โ pin "modal_stack", to: "modal_stack.js" โ
โ app/javascript/controllers/application.js โ
โ import { install } from "modal_stack" โ
โ install(application) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโ jsbundling (esbuild / vite / bun) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ package.json โ "@hotwired/stimulus": "^3" โ
โ app/javascript/controllers/application.js โ
โ import { install } from "modal_stack" โ
โ install(application) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโ Sprockets (legacy) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ app/assets/config/manifest.js โ
โ //= link modal_stack.js โ
โ //= link modal_stack/<provider>.css โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Importmap-friendly bundle is pre-built and committed at
app/assets/javascripts/modal_stack.js (Stimulus + Turbo are externals,
provided by the host app).
โฟ Accessibility
- Native
<dialog>โ modern browsers handle focus trap, ESC, andaria-modalfor free. - Inertness โ underlying layers in a stack receive
inert, so screen-readers and keyboard navigation skip them. - Reduced motion โ when
prefers-reduced-motion: reduceis set, presets collapse transitions to 1ms. - Focus restoration โ when a layer is popped, focus returns to the trigger element (per
<dialog>semantics). - Body scroll lock โ
<body data-modal-stack-locked>prevents background scroll while the dialog is open.
๐งช Development
$ git clone https://github.com/Metalzoid/modal_stack.git
$ cd modal_stack
$ bin/setup
$ bundle exec rake # rspec + rubocop
$ bundle exec rspec # Ruby specs (incl. system specs via Cuprite)
$ bun test # JS unit tests (state, orchestrator, runtime)
$ bin/build # rebuild app/assets/javascripts/modal_stack.js
System specs require Google Chrome locally:
$ brew install --cask google-chrome
Test against a specific Rails version:
$ bundle exec appraisal install
$ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake
$ BUNDLE_GEMFILE=gemfiles/rails_8_1_sprockets.gemfile bundle exec rake
Repo layout
modal_stack/
โโโ app/
โ โโโ assets/
โ โ โโโ javascripts/modal_stack.js # pre-built importmap bundle (committed)
โ โ โโโ stylesheets/modal_stack/ # tailwind / bootstrap / vanilla presets
โ โโโ javascript/modal_stack/ # ES module sources + bun tests
โ โ โโโ state.js # pure reducer (100% coverage)
โ โ โโโ orchestrator.js # state โ command translator
โ โ โโโ runtime.js # BrowserRuntime IO adapter
โ โ โโโ install.js # Stimulus install hook
โ โ โโโ controllers/ # Stimulus controllers
โ โโโ views/layouts/modal.html.erb
โโโ lib/
โ โโโ modal_stack.rb # entry point + Engine
โ โโโ modal_stack/
โ โ โโโ configuration.rb
โ โ โโโ controller_extensions.rb
โ โ โโโ turbo_streams_extension.rb
โ โ โโโ helpers/ # ActionView helpers
โ โ โโโ capybara{.rb,/rspec.rb,/minitest.rb}
โ โโโ generators/modal_stack/install/
โโโ spec/
โ โโโ dummy/ # minimal Rails app for system specs
โ โโโ system/ # Capybara + Cuprite suite
โโโ Appraisals # Rails 7.2 โ 8.1 (+sprockets) variants
โโโ gemfiles/ # per-version gemfiles (generated)
๐ Releasing
- Bump
lib/modal_stack/version.rbto the next semantic version. - Move
[Unreleased]items to a new dated section inCHANGELOG.md. - Push to
main. The release workflow will:- create and push the
vX.Y.Zannotated tag, - build the gem and create a GitHub Release with auto-generated notes,
- publish to RubyGems via OIDC trusted publishing.
- create and push the
To re-release an existing version, push the tag manually:
$ git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0
๐ค Contributing
Bug reports and pull requests welcome on GitHub at https://github.com/Metalzoid/modal_stack.
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Make sure the full default task passes (
bundle exec rake) and JS tests are green (bun test) - If you touched
app/javascript/, rebuild the importmap bundle (bin/build) and commit the result - Push (
git push origin my-new-feature) - Open a Pull Request
CI runs the full Ruby matrix (Ruby 3.2-4.0 ร Rails 7.2-8.1) plus the JS suite, the build smoke test, and a bundle-freshness check that catches PRs that edited the JS source without rebuilding the bundle.
๐ License
Released under the MIT License.