stimulus-zag
v1.0.0-rc.1
Published
Headless, accessible UI components for Rails + Stimulus, powered by Zag.js. Bring your own Tailwind styles.
Maintainers
Readme
stimulus-zag
Headless, accessible UI components for Rails + Stimulus, powered by Zag.js finite state machines. stimulus-zag ships the behavior (keyboard nav, focus management, ARIA, open/close state) as Stimulus controllers — you bring the markup and style it with Tailwind (or anything else).
- Accessible by default — all the WAI-ARIA details are handled by Zag.js.
- Headless — zero opinions about styling. Author plain HTML, add classes.
- Rails-native DX — plain
data-controller/data-partattributes, no JSX. - Complete — 37 components wrapping the full Zag.js catalog.
- Tiny — the library itself is a thin adapter; Zag machines are shared deps.
📖 Documentation
Full docs, with a live interactive demo and the values/parts/events reference for every component, live at:
https://tsln-lab.github.io/stimulus-zag/
Run the docs locally with npm run docs:dev.
Installation
npm install stimulus-zag @hotwired/stimulus
# or: yarn add / bun add / pnpm addstimulus-zag is distributed as an npm package, so it works with any modern Rails JS
setup that bundles JavaScript — jsbundling-rails (esbuild/rollup), vite_rails,
Webpacker, etc. @hotwired/stimulus is a peer dependency.
Using importmap-rails? Add the companion gem instead — it ships a
self-contained bundle so you don't have to pin every @zag-js/* package:
# Gemfile
gem "stimulus-zag"bundle install
bin/rails generate stimulus_zag:installSee the Rails guide for details.
Setup
Register the controllers on your Stimulus application:
// app/javascript/controllers/index.js
import { Application } from "@hotwired/stimulus"
import { registerStimulusZag } from "stimulus-zag"
const application = Application.start()
registerStimulusZag(application)When you bundle your JS (esbuild, Vite, Webpacker), register only what you use so
the rest tree-shakes away — the register helper reads each controller's
identifier:
import { register, MenuController, DialogController } from "stimulus-zag"
register(application, MenuController, DialogController)Registering all 37 is ~731 KB bundled; Menu + Dialog is ~131 KB. See the Rails guide for the importmap gem and a full size breakdown.
The data-part convention
Every component maps its Zag anatomy onto your DOM through
data-part attributes. You write semantic HTML, tag each element with the part
it plays, and stimulus-zag wires up the behavior on connect and re-applies it on every
state change:
<div data-controller="zag-menu">
<button data-part="trigger">…</button>
<div data-part="positioner">
<div data-part="content">
<button data-part="item" data-value="edit">Edit</button>
</div>
</div>
</div>Collection parts (like menu items) identify themselves with data-value.
Mark one disabled with data-disabled. Multi-word parts use the kebab-case name
Zag emits — e.g. the accordion's item trigger is data-part="item-trigger".
State is reflected back onto each part as data-* attributes
(data-[state=open], data-[highlighted], data-[disabled], …) so you style
every state with Tailwind's arbitrary variants. See the
Styling guide for the
full list and a couple of important gotchas (hiding collapsed parts before the
controller connects, and stacking popups with z-*).
Components
All 37 components — each with a live demo and its full options / parts / events reference — are documented on the docs site.
Forms & inputs: Checkbox · Radio Group · Switch · Toggle Group · Slider · Angle Slider · Number Input · Pin Input · Rating Group · Editable · Tags Input · Select · Combobox · Date Picker · Time Picker · Color Picker · File Upload · Signature Pad
Overlays: Menu · Dialog · Popover · Tooltip · Hover Card · Toast · Tour
Navigation & disclosure: Accordion · Collapsible · Tabs · Pagination · Tree View
Data display & media: Avatar · Carousel · Progress · Timer · QR Code · Splitter · Clipboard
Adding a component
stimulus-zag's base — ZagController — handles the entire
Zag lifecycle (create machine → start → re-spread props on every state change →
clean up). A new component is mostly declarative: point it at a Zag machine and
describe how each anatomy part maps to the DOM.
import * as accordion from "@zag-js/accordion"
import { ZagController, type PartsMap } from "stimulus-zag"
export class AccordionController extends ZagController<accordion.Api> {
static values = { collapsible: { type: Boolean, default: true } }
declare readonly collapsibleValue: boolean
static parts: PartsMap<accordion.Api> = {
root: (api) => api.getRootProps(),
// singleton / stateless parts → one function
// collection parts → { each: (api, el) => ... }, reading data-* off the element
item: { each: (api, el) => api.getItemProps({ value: el.dataset.value! }) },
itemTrigger: { each: (api, el) => api.getItemTriggerProps({ value: el.dataset.value! }) },
itemContent: { each: (api, el) => api.getItemContentProps({ value: el.dataset.value! }) },
}
protected machine: accordion.Machine = accordion.machine
protected connectApi = accordion.connect
protected get machineProps() {
return { collapsible: this.collapsibleValue }
}
// Re-configure the running machine when a value changes:
collapsibleValueChanged() {
this.reconfigure()
}
}Then register it (application.register("zag-accordion", AccordionController))
and author markup with matching data-part attributes. That's it — start,
state syncing, event listeners, nested-controller scoping, and teardown are all
inherited.
Development
npm install
npm run build # bundle ESM + CJS + type declarations (tsup)
npm run typecheck # tsc --noEmit
npm test # vitest (happy-dom) — drives real Stimulus + real Zag machines
npm run dev # tsup --watch
npm run docs:dev # run the documentation site locally (VitePress)
npm run docs:build # build the documentation siteHow it works
Each controller wraps Zag's framework-agnostic
VanillaMachine:
connect()builds the machine from yourdata-*-valueoptions and starts it.- On every state transition, the controller re-runs
connect(service, normalize)and spreads the resulting props (aria-*,data-state, event handlers, …) onto the matching[data-part]elements via Zag'sspreadProps(which diffs attributes and swaps listeners in place). disconnect()removes listeners and stops the machine.
