Rails
stimulus-zag works in any Stimulus app. In Rails there are two ways to install it — pick the one that matches how your app handles JavaScript.
With a bundler (esbuild, Vite, Webpacker)
If your app uses jsbundling-rails, vite_rails, or similar, install the npm package:
npm install stimulus-zag @hotwired/stimulusYou can register everything with registerStimulusZag(application), but that pulls in all 37 components. Since you're bundling, register only what you use — the package is side-effect-free, so a bundler drops the rest. Use the register helper, which reads each controller's built-in identifier so you don't repeat the zag-* strings:
// app/javascript/controllers/index.js
import { application } from "./application"
import { register, MenuController, DialogController } from "stimulus-zag"
register(application, MenuController, DialogController)The difference is large. Bundled with esbuild (@hotwired/stimulus external, minified):
| What you register | Bundle |
|---|---|
Everything (registerStimulusZag) | ~731 KB |
| Menu + Dialog + Tabs | ~131 KB |
| Menu only | ~95 KB |
So import components as you adopt them and the bundle grows only with what you actually use.
With importmap-rails (the gem)
Importmap apps don't bundle, so pulling in stimulus-zag from a CDN would mean pinning it and its ~38 @zag-js/* dependencies. The stimulus-zag gem avoids that: it ships a single self-contained bundle and pins it for you.
Add it to your Gemfile:
gem "stimulus-zag"Then install:
bundle install
bin/rails generate stimulus_zag:installThe generator adds the registration to your Stimulus entrypoint:
// app/javascript/controllers/application.js
import { registerStimulusZag } from "stimulus-zag"
registerStimulusZag(application)That's it. The gem serves the bundle through the asset pipeline (Propshaft or Sprockets) and contributes the stimulus-zag importmap pin automatically, so there's nothing to add to config/importmap.rb.
Requires stimulus-rails
The bundle imports @hotwired/stimulus, which your app already provides via stimulus-rails. No extra pin needed.
Prefer CDN / granular pins?
If you'd rather resolve the package and each Zag dependency from a CDN (for example, to share a single copy with other Zag-based libraries), let importmap-rails generate the pins instead:
bin/importmap pin stimulus-zagThat writes granular pins to config/importmap.rb, which override the gem's vendored pin. You can then drop the gem if you only wanted it for delivery.
Registering a subset
However you install it, you can register only the controllers you use instead of all 37. The register helper reads each controller's built-in identifier:
import { register, MenuController, DialogController } from "stimulus-zag"
register(application, MenuController, DialogController)When you bundle (esbuild, Vite, Webpacker), this also keeps the bundle small — the components you don't import tree-shake away.
Testing an unpublished build
To try a local build in your app before anything is published to npm or RubyGems, use whichever install path matches your app.
esbuild / jsbundling (npm)
Pack a tarball from the library repo and install it — this mirrors a real install and, importantly, dedupes the @zag-js/* packages into a single copy (loading two copies of a Zag machine breaks state updates):
# in the stimulus-zag repo
npm run build # dist/ must exist before packing
npm pack # → stimulus-zag-1.0.0-rc.1.tgz# in your Rails app
npm install /absolute/path/to/stimulus-zag-1.0.0-rc.1.tgzWire it up as usual, then rebuild your JS:
// app/javascript/controllers/index.js
import { application } from "./application"
import { register, MenuController } from "stimulus-zag"
register(application, MenuController)To pick up a library change, re-run npm run build && npm pack and npm install …tgz again.
Avoid npm link for this package
npm link (and "stimulus-zag": "file:../path") resolves @zag-js/* from the library's own node_modules while your app has its own — two copies of each machine, which causes subtle "state won't update" bugs. Prefer the tarball. If you need link-style live reload, alias @zag-js/* in your bundler to your app's copy so there's only one.
importmap-rails (the gem)
Point a path: gem at the repo. Build the JS first so the vendored bundle exists:
# in the stimulus-zag repo
npm run build# your app's Gemfile
gem "stimulus-zag", path: "/absolute/path/to/stimulus-zag"bundle install
bin/rails generate stimulus_zag:install