angular, react, and vue are comprehensive UI frameworks used to build entire web applications, managing rendering, routing, and state. @zag-js/core is different — it is a framework-independent logic layer that handles complex interactive component behavior (like menus, dialogs) using state machines, often used inside the other three. While the frameworks decide how your app looks and updates the screen, Zag decides how complex widgets behave and stay accessible.
When architecting a modern web application, you typically choose a UI framework to handle rendering and overall structure. However, for complex interactive widgets, you might also need a dedicated logic layer. angular, react, and vue are full frameworks for building applications. @zag-js/core is a utility for managing component state machines. Let's compare how they handle state, accessibility, and rendering.
react, vue, and angular are responsible for rendering UI to the screen. They manage the DOM, handle updates, and structure your application.
// react: Renders JSX to DOM
function Button() {
return <button>Click Me</button>;
}
// vue: Renders Template to DOM
// <template><button>Click Me</button></template>
export default { name: 'Button' };
// angular: Renders Template to DOM
// @Component({ template: '<button>Click Me</button>' })
export class ButtonComponent {}
@zag-js/core does not render anything. It provides the logic (state machine) that you connect to your framework of choice.
// zag: Provides logic, no rendering
import { createMachine } from '@zag-js/core';
const machine = createMachine({
id: 'button',
context: { pressed: false },
states: { idle: {}, pressed: {} }
});
react uses local state hooks that trigger re-renders when changed. It is simple but can get complex for multi-step interactions.
// react: useState hook
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
vue uses reactive references that update the view automatically when the value changes.
// vue: ref reactive state
const isOpen = ref(false);
const toggle = () => isOpen.value = !isOpen.value;
angular uses signals (in modern versions) to track state changes and update the view efficiently.
// angular: signal state
const isOpen = signal(false);
const toggle = () => isOpen.update(v => !v);
@zag-js/core uses a state machine pattern. You send events to the machine, and it transitions states based on defined rules. This prevents invalid states.
// zag: Machine transitions
const service = interpret(machine);
service.start();
service.send({ type: 'TOGGLE' }); // Transitions based on logic
react, vue, and angular require you to manually manage ARIA attributes and keyboard interactions for complex widgets. This is error-prone.
// react: Manual ARIA management
<button
aria-expanded={isOpen}
onClick={toggle}
>
Menu
</button>
// vue: Manual ARIA management
<button
:aria-expanded="isOpen"
@click="toggle"
>
Menu
</button>
// angular: Manual ARIA management
// <button [attr.aria-expanded]=
Choose @zag-js/core when you need to build complex, accessible interactive widgets like comboboxes or date pickers without managing the state machine logic yourself. It is perfect for design system teams who want to write the logic once and use it across React, Vue, or Angular projects.
Choose angular if you are building large-scale enterprise applications that benefit from a strict, opinionated structure. It includes built-in solutions for HTTP, forms, and routing, reducing the need to select third-party libraries. It is best for teams that value long-term stability and strong TypeScript integration.
Choose react if you want maximum flexibility and access to the largest ecosystem of third-party tools. It is ideal for projects where you want to choose your own routing, state management, and styling solutions. It works well for teams comfortable with JavaScript-heavy patterns and hooks.
Choose vue if you prefer a progressive framework that is easy to start with but scales well. It offers a balanced approach with single-file components and a clear separation of concerns. It is suitable for teams wanting a gentle learning curve without sacrificing power in complex applications.
This package contains a minimal implementation of XState FSM for finite state machines with addition of extra features we need for our components.
and, or, not)Note: The core package is intentionally minimal. It doesn't include delayed transitions, interval scheduling, spawn helpers, or activities. Use actions/effects to perform side effects.
To better understand the state machines, we strongly recommend going though the xstate docs and videos. It'll give you the foundations you need.
Installation
npm i @zag-js/core
# or
yarn add @zag-js/core
Usage (machine):
import { createMachine } from "@zag-js/core"
const toggleMachine = createMachine({
id: "toggle",
initialState() {
return "inactive"
},
states: {
inactive: { on: { TOGGLE: "active" } },
active: { on: { TOGGLE: "inactive" } },
},
})
Usage (service via adapter):
import { createMachine } from "@zag-js/core"
import { VanillaMachine } from "@zag-js/vanilla"
const machine = createMachine({
initialState() {
return "inactive"
},
states: {
inactive: { on: { TOGGLE: "active" } },
active: { on: { TOGGLE: "inactive" } },
},
})
const service = new VanillaMachine(machine)
service.start()
service.subscribe(() => {
console.log(service.state.get())
})
service.send({ type: "TOGGLE" })
service.send({ type: "TOGGLE" })
service.stop()
import { createMachine } from "@zag-js/core"
const machine = createMachine({
initialState() {
return "dialog"
},
states: {
dialog: {
tags: ["overlay"],
initial: "closed",
states: {
closed: { on: { OPEN: { target: "dialog.open" } } },
open: { on: { CLOSE: { target: "dialog.closed" } } },
},
// parent-level transitions still work
on: { RESET: { target: "dialog.closed" } },
},
},
})
// service.state.matches("dialog.open") === true when nested state is active
#id targetsUse id on a state node when you want to target it explicitly from anywhere in the machine.
import { createMachine } from "@zag-js/core"
const machine = createMachine({
initialState() {
return "dialog"
},
states: {
dialog: {
initial: "open",
states: {
focused: {
id: "dialogFocused",
},
open: {
initial: "idle",
states: {
idle: {
on: {
CLOSE: { target: "#dialogFocused" },
},
},
},
},
},
},
},
})
Notes:
#id targets resolve by state node id (XState-style).createMachine(config, options)Creates a new finite state machine from the config.
| Argument | Type | Description |
|---|---|---|
config | object (see below) | The config object for creating the machine. |
options | object (see below) | The optional options object. |
Returns:
A machine definition object consumed by framework adapters (React, Solid, Svelte, Vue, Preact, Vanilla) to create a runtime service.
The machine config has this schema:
initialState (function) - returns the machine's initial state value.states (object) - mapping of state names to state configs. Supports nested states and initial for child states.on (object) - optional global transitions available from any state.context (function) - returns bindable context fields.computed (object) - derived values based on context, props, refs.entry / exit / effects - root-level actions/effects.implementations (object) - lookup tables for actions, guards, effects.on (object) - an object mapping event types (keys) to transitionsString syntax:
{ target: stateName }Object syntax:
target (string) - the state name to transition to.actions (Action | Action[]) - the action(s) to execute when this transition is taken.guard (Guard) - the condition (predicate function) to test. If it returns true, the transition will be taken.actions? (object) - a lookup object for your string actions.guards? (object) - a lookup object for your string guards specified as guard in the machine.activities? (object) - a lookup object for your string activities.delays? (object) - a lookup object for your string delays used in after and every config.The action function to execute while transitioning from one state to another. It takes the following arguments:
context (any) - the machine's current context.event (object) - the event that caused the action to be executed.