Skip to content

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:

sh
npm install stimulus-zag @hotwired/stimulus

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

js
// 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 registerBundle
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:

ruby
gem "stimulus-zag"

Then install:

sh
bundle install
bin/rails generate stimulus_zag:install

The generator adds the registration to your Stimulus entrypoint:

js
// 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:

sh
bin/importmap pin stimulus-zag

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

js
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):

sh
# in the stimulus-zag repo
npm run build          # dist/ must exist before packing
npm pack               # → stimulus-zag-1.0.0-rc.1.tgz
sh
# in your Rails app
npm install /absolute/path/to/stimulus-zag-1.0.0-rc.1.tgz

Wire it up as usual, then rebuild your JS:

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:

sh
# in the stimulus-zag repo
npm run build
ruby
# your app's Gemfile
gem "stimulus-zag", path: "/absolute/path/to/stimulus-zag"
sh
bundle install
bin/rails generate stimulus_zag:install