Skip to main content
A finite state machine is a model of computation where a system can be in exactly one of a finite number of states at any given time. Transitions between states are triggered by events and can carry side effects (called actions and effects).In the context of UI components, state machines make implicit behavior explicit. Instead of tracking isOpen, isFocused, isAnimating, and isDisabled as separate booleans and reasoning about their combinations, you define named states — idle, open, focused, disabled — and declare exactly which events cause transitions between them.This produces component logic that is predictable, testable, and easy to debug. Because the valid states and transitions are defined up front, impossible states (e.g. simultaneously open and disabled) simply cannot be reached.
Zag was inspired by XState and shares its foundational ideas, but it does not implement the full SCXML specification. Instead, Zag uses a simpler, purpose-built API optimized for the specific patterns common in UI components.The Zag machine format avoids complex machine concepts like spawn, deeply nested states, and parallel state nodes. The goal is to keep machines light-weight and easy to understand at a glance. This trade-off suits headless component libraries well — the behavior space of a tooltip or a dialog is well-defined enough that full statechart expressiveness is rarely needed.If you are already using XState for application-level state and want to use Zag for component-level logic, both can coexist in the same project.
Yes. The machine packages (@zag-js/tooltip, @zag-js/dialog, etc.) have no dependency on any framework. You can import and run them directly in any JavaScript environment.The framework adapters (@zag-js/react, @zag-js/vue, etc.) are optional conveniences that integrate useMachine with a framework’s reactivity system and provide the correct normalizeProps implementation.In a vanilla JS context you would start the machine manually, subscribe to state changes, and apply props to DOM elements yourself. This is the same model the framework adapters use internally — they just automate the subscription and re-render step.
Zag is completely headless. The connect function returns props and state — it never applies inline styles, CSS classes, or any visual presentation.You can use any styling approach:
  • CSS classes — Spread the returned props and add your own className alongside them.
  • Data attributes — Zag sets data-* attributes on elements (e.g. data-open, data-disabled, data-highlighted) that you can target with CSS selectors.
  • Tailwind CSS — Combine Tailwind utility classes with the data attribute selectors Zag sets.
  • CSS-in-JS — Style the component using any runtime styling library.
The data attributes are the recommended styling hook because they are stable across Zag versions and reflect the actual machine state.
Yes. The machine packages contain no DOM references at import time. Machines are started inside framework lifecycle hooks (useEffect in React, onMounted in Vue, onMount in Solid) so they never run during server rendering.The React adapter is marked "use client" for compatibility with React Server Components. Non-interactive server components can freely import types and static utilities from Zag packages without triggering client-side execution.
Accessibility is a primary design goal, not an afterthought. All component machines are modeled according to the WAI-ARIA Authoring Practices. The connect function returns the correct ARIA roles, states, and properties for every element.Each machine also implements the expected keyboard interaction patterns defined by WAI-ARIA — arrow key navigation in listboxes, Escape to close dialogs, roving tabindex in radio groups, and so on.End-to-end tests are written for every component and run against all supported frameworks using Playwright. The test suite verifies that keyboard and pointer interactions work correctly regardless of which framework is used.
All three libraries are headless component primitives focused on accessibility, but they differ in architecture and scope.Framework coupling — Radix UI targets React only. Headless UI supports React and Vue with separate packages. Zag separates the component logic from the framework entirely, so the same machine runs in React, Vue, Solid, and Svelte without duplication.State machines — Radix and Headless UI manage state with standard framework primitives (useState, ref, etc.). Zag uses an explicit state machine model, which makes the component’s behavior fully enumerable and testable independently of any framework.Styling API — All three are unstyled. Radix and Headless UI expose data attributes. Zag also exposes data attributes and follows the same pattern.If you are building a cross-framework design system or a component library that must work in more than one framework, Zag’s architecture avoids duplicating interaction logic for each target.
The connect function in each machine package produces props using a normalized internal format. normalizeProps converts those props into the specific format that each framework requires before you spread them onto DOM elements.There are subtle but real differences between frameworks:
  • Event handler casing — React and Solid use onKeyDown; Vue uses onKeydown.
  • Inline style values — React accepts numeric pixel values ({ marginBottom: 4 }); Solid requires CSS property names with string units ({ "margin-bottom": "4px" }); Vue requires string values with units ({ marginBottom: "4px" }).
These differences are handled automatically. You import normalizeProps from the adapter for your framework (@zag-js/react, @zag-js/vue, etc.) and pass it to connect. The machine handles the rest.normalizeProps also ensures that the props returned by connect are strongly typed to the correct element attribute types for your framework.
Zag currently provides official adapters for:
  • React (@zag-js/react) — React 18+
  • Vue (@zag-js/vue) — Vue 3
  • Solid (@zag-js/solid) — Solid.js
  • Svelte (@zag-js/svelte) — Svelte 5
Support for additional frameworks is possible. The key requirements for a new adapter are support for spreading props (attributes and event handlers) onto elements, and ideally exposed TypeScript typings for element attributes.
Because Zag returns plain props objects, you can merge your own handlers with the ones returned by connect. The recommended pattern is to compose the event handlers so both run:
<button
  {...api.getTriggerProps({
    onClick(event) {
      // your custom handler runs alongside the machine's handler
      console.log("clicked")
    },
  })}
>
  Hover me
</button>
Most get*Props functions accept an optional props argument that is merged with the machine’s own props before being returned.

Build docs developers (and LLMs) love