Skip to content

Repository files navigation

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-part attributes, no JSX.
  • Complete37 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:

Run the docs locally with npm run docs:dev.

Installation

npm install stimulus-zag @hotwired/stimulus
# or: yarn add / bun add / pnpm add

stimulus-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:install

See 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 site

How it works

Each controller wraps Zag's framework-agnostic VanillaMachine:

  1. connect() builds the machine from your data-*-value options and starts it.
  2. 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's spreadProps (which diffs attributes and swaps listeners in place).
  3. disconnect() removes listeners and stops the machine.

License

MIT

Releases

Packages

Contributors

Languages