๐ 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. - ๐ค๏ธ Path inside a layer โ
modal_path_to/modal_path_backfor wizards & flows: each step gets its own URL and history entry, browser-back walks frames one by one, the X button collapses the whole path in a single jump. - ๐ช 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 or step-back โ frame paths collapse to single history jumps when the layer closes.
- ๐ฎ Imperative Turbo Stream actions โ
turbo_stream.modal_push / modal_pop / modal_replace / modal_close_all / modal_path_to / modal_path_backfrom anywhere. - ๐จ Four CSS presets โ Tailwind v4, Tailwind v3, Bootstrap, vanilla. All driven by
--modal-stack-*CSS variables for easy retheming. Frame transitions (slide,fade) are fully implemented in every preset via@starting-style. - ๐ช Four variants โ
modal,drawer(left/right/top/bottom),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,within_modal_frame,have_modal_open,have_modal_stack(depth: 2),have_modal_frames(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_v4 # default โ chains on Tailwind v4 @theme tokens
$ bin/rails g modal_stack:install --css-provider=tailwind_v3 # static values aligned with Tailwind v3
$ 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_v4 # :tailwind_v3 | :tailwind_v4 | :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.default_path_transition = :slide # :slide | :fade | :none
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),
use modal_path_to to advance and modal_path_back (or the
modal_back_link helper) to step back. The current frame's HTML is cached
in memory, so back-navigation is instant โ no network round-trip โ and
each forward step pushes a real history entry, so browser-back walks the
frames one by one. When the user closes the layer (X / ESC / backdrop /
modal_pop), every frame's history entry collapses in a single jump.
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_path_to(
template: "wizard/step_2",
url: wizard_step_2_path
# transition: :fade # :slide (default from config) | :fade | :none
# stale: true # force a refetch when the user steps back here
)
end
end
end
end
<%# wizard/step_2.html.erb %>
<%= modal_stack_container(back: true) do %>
<h2>Step 2</h2>
<%# โฆ form โฆ %>
<%= modal_back_link "Back" %>
<%# or modal_back_link "Restart", steps: 99 # clamped at the first frame %>
<% end %>
back: true on modal_stack_container injects a back-button slot โ
hidden by CSS at [data-frame-depth="1"], visible from frame 2 onward.
To force a refresh when the user steps back to a frame (e.g. its data is
stale), either pass stale: true to modal_path_to or set the
X-Modal-Stack-Stale: true header on the response. The runtime will
refetch the URL instead of restoring from cache.
โ ๏ธ
replaceTopcollapses path frames. When the top layer has a path and you callturbo_stream.modal_replace, the path is forgotten: the layer is morphed back to a single frame and the surplus history entries are walked back. Usemodal_path_backif you want to step back,modal_replaceif you want to swap the entire layer.
If you'd rather not keep a back-history (e.g. each step replaces the
previous and there's no going back), use the older modal_replace
mechanism with history: :push instead โ the URL still changes per
step, but no in-memory cache is kept and replaceTop is the right tool.
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_v3 |
One of :tailwind_v3, :tailwind_v4, :bootstrap, :vanilla, :none. Determines which stylesheet modal_stack_stylesheet_link_tag resolves to. The legacy :tailwind is accepted and normalized to :tailwind_v3. New installs default to :tailwind_v4. 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. |
default_path_transition |
Symbol | :slide |
Default transition for modal_path_to / modal_path_back when no per-call transition: is given. One of :slide, :fade, :none. 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:, back:, transition:, html: {}) { ... } |
Wraps a panel view with the markup the JS runtime expects. back: true injects a back-button slot wired to modal-stack#pathBack (hidden by CSS at the first frame); transition: writes data-modal-stack-transition for host CSS hooks. |
modal_back_link(name = nil, **opts) { ... } |
Renders a <button> wired to the modal-stack-back-link Stimulus controller. Pass steps: (default 1) to walk back multiple frames in a single click โ clamped at the first frame, never closes the layer. Block form supported for custom markup (e.g. <%= modal_back_link(class: "btn") { "โ Back" } %>). |
modal_stack_stylesheet_link_tag(**options) |
Emits <link rel="stylesheet"> for the configured preset (modal_stack/tailwind_v4.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:. Resets the path to a single frame when the top layer has multiple frames. |
modal_close_all |
โ |
modal_path_to(content = nil, **opts, &block) |
url:, transition: (:slide (default from config) / :fade / :none), stale: (when true, runtime refetches on back), layer_id:, plus any rendering options. |
modal_path_back(steps: 1, transition: nil) |
steps: (positive integer, clamped at the first frame), transition: override. |
history: :push raises ArgumentError if given any value other than
:push or :replace. Unknown transitions on modal_path_to /
modal_path_back raise ArgumentError as well.
๐ก Stale frames. When you want a frame to refetch on back instead of restoring from the in-memory cache, either pass
stale: truetomodal_path_toor set theX-Modal-Stack-Stale: trueheader on the response. Useful when the frame's data is expected to change between the forward visit and the back visit (e.g. a list that the user mutated downstream).
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-frame-index="1"
data-frame-depth="2"
data-modal-stack-size="lg"
data-modal-stack-width="42rem" style="width: 42rem;">
<div data-modal-stack-frame
data-frame-index="1"
data-transition="slide"
data-direction="forward">
<!-- panel content -->
</div>
</div>
The frame wrapper sits between the layer and the panel content. It
defaults to display: contents so it is invisible to host CSS. When a
transition is requested, the runtime sets [data-transition] and
[data-direction] on the entering frame โ the shipped presets pick
these up via @starting-style rules (slide: translate from right/left,
fade: opacity 0โ1) and restore overflow-y: auto on the layer once
transitionend fires. data-frame-depth="N" on the layer reflects the
current path length โ 1 for layers without a path, N for layers N
frames deep.
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
All three 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. Also exposes a public pathBack action โ wire it on any element via data-action="click->modal-stack#pathBack" (with optional data-modal-stack-steps-param="2"). |
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. |
modal-stack-back-link |
Attached to elements rendered by modal_back_link. On click, calls orchestrator.pathBack({ steps }) โ never closes the layer; clamps at the first frame. |
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,
pathTo, pathBack,
snapshot, restore, topLayer, VARIANTS, TRANSITIONS,
ModalStackDepthError,
// orchestrator + browser runtime
Orchestrator, BrowserRuntime,
FRAGMENT_HEADER, STALE_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, mountFrame, unmountFrame,
clearFrameCache, 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/modal_path_to/modal_path_back) 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. |
within_modal_frame(depth: nil) { ... } |
Scopes Capybara matchers to the current frame inside a layer (the one that's not animating out). Useful when a path is in flight. |
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. |
have_modal_frames(count) |
Matcher: asserts the top layer's path has count frames. Layers without a path read as 1. |
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_v4 |
tailwind_v3, tailwind_v4, bootstrap, vanilla, none (legacy tailwind accepted, normalized to tailwind_v3) |
--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
Four opinionated stylesheets ship with the gem. Pick one with
config.css_provider:
| Preset | File | Best for |
|---|---|---|
:tailwind_v4 |
app/assets/stylesheets/modal_stack/tailwind_v4.css |
Tailwind v4 apps โ chains on @theme tokens (--color-*, --radius-*, --shadow-*, --container-*) so the modal picks up your theme automatically. Falls back to Tailwind defaults when @theme isn't redefined. |
:tailwind_v3 |
app/assets/stylesheets/modal_stack/tailwind_v3.css |
Tailwind v3 apps โ static values aligned with Tailwind v3 defaults (v3 doesn't expose tokens as CSS variables). Legacy :tailwind is accepted as an alias. |
: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 four presets share the following capabilities:
- Frame transitions โ
slide(horizontal translate via@starting-style) andfade(opacity) are both fully implemented. The entering frame animates in; the layer clips off-screen frames withoverflow: hiddenfor the duration, then restoresoverflow-y: auto. - All four drawer sides โ
left,right,top,bottomwith matching entry/exit animations. - Mobile scroll containment โ
overscroll-behavior: containprevents scroll chaining when modal content reaches its boundary. - Safe-area inset โ
bottom_sheetanddrawer[data-side="bottom"]applyenv(safe-area-inset-bottom)padding. - Keyboard focus ring โ
.modal-stack__panel-backhas a:focus-visibleoutline. - Reduced-motion โ frame transition durations collapse to
1msalongside layer transitions.
All 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 all transitions (layer and frame) to 1 ms. - Focus restoration โ when a layer is popped, focus returns to the trigger element (per
<dialog>semantics). - Back-button focus ring โ
.modal-stack__panel-backrenders a:focus-visibleoutline on keyboard focus in every preset. - Body scroll lock โ
<body data-modal-stack-locked>prevents background scroll while the dialog is open;overscroll-behavior: containon layers additionally blocks scroll chaining (pull-to-refresh, iOS bounce) when modal content is scrolled to its boundary. - Safe-area padding โ
bottom_sheetand bottom drawers applyenv(safe-area-inset-bottom)so content is never hidden under the iOS home indicator.
๐งช 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_v3 / tailwind_v4 / 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.