Skip to content

Getting started

Install

sh
npm install stimulus-zag

@hotwired/stimulus is a peer dependency (v3+); the relevant @zag-js/* packages come along as dependencies.

Register the controllers

Register everything at once:

js
import { Application } from "@hotwired/stimulus"
import { registerStimulusZag } from "stimulus-zag"

const application = Application.start()
registerStimulusZag(application)

Or, when you bundle your JavaScript, register only what you use so the rest tree-shakes away. The register helper reads each controller's identifier:

js
import { Application } from "@hotwired/stimulus"
import { register, MenuController } from "stimulus-zag"

const application = Application.start()
register(application, MenuController)

Each controller is also available on its own subpath, if you prefer explicit per-component imports (stimulus-zag/<component>):

js
import { MenuController } from "stimulus-zag/menu"
import { DialogController } from "stimulus-zag/dialog"

Both forms bundle the same thanks to tree-shaking; the subpath is just more explicit and works in environments that don't tree-shake.

Rails

Using importmap-rails or esbuild / jsbundling? See the Rails guide — the companion gem removes the need to pin every Zag package for importmap apps, and there's a bundle-size breakdown for selective registration.

Add a component

Author the markup with data-part attributes that match the component's anatomy, add the data-controller, and style it however you like:

html
<div data-controller="zag-menu" class="relative inline-block text-left">
  <button
    data-part="trigger"
    class="border-input bg-popover text-foreground hover:bg-accent focus-visible:ring-ring data-[state=open]:bg-accent inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium shadow-sm transition focus:outline-none focus-visible:ring-2"
  >
    Actions
    <span
      data-part="indicator"
      class="text-muted-foreground transition-transform data-[state=open]:rotate-180"
      aria-hidden="true"
      >▾</span
    >
  </button>

  <div data-part="positioner">
    <div
      data-part="content"
      hidden
      class="border-border bg-popover z-50 min-w-44 rounded-lg border p-1 shadow-lg focus:outline-none"
    >
      <button
        data-part="item"
        data-value="edit"
        class="text-foreground data-[highlighted]:bg-primary data-[highlighted]:text-primary-foreground flex w-full cursor-pointer items-center rounded-md px-3 py-2 text-sm"
      >
        Edit
      </button>
      <button
        data-part="item"
        data-value="duplicate"
        class="text-foreground data-[highlighted]:bg-primary data-[highlighted]:text-primary-foreground flex w-full cursor-pointer items-center rounded-md px-3 py-2 text-sm"
      >
        Duplicate
      </button>
      <hr data-part="separator" class="border-border my-1" />
      <button
        data-part="item"
        data-value="delete"
        data-disabled
        class="text-destructive data-[highlighted]:bg-destructive flex w-full items-center rounded-md px-3 py-2 text-sm data-[disabled]:cursor-not-allowed data-[highlighted]:text-white data-[disabled]:opacity-40"
      >
        Delete
      </button>
    </div>
  </div>
</div>

That's it — no build-time templates, no per-component setup. The controller connects on page load (and after any Turbo navigation).

Next steps