Make Your Design System DRY With Zag
Design systems promise consistency and efficiency, yet many teams find themselves trapped in a cycle of code duplication and maintenance nightmares. When the same dropdown menu needs to work in React, Vue, and Solid, you often end up maintaining three separate implementations--each with subtle bugs and inconsistencies. Zag.js offers a different approach: state machines that encapsulate component logic independently of any framework, enabling true code reuse while improving accessibility and testability.
In this guide, you'll discover how Zag transforms complex interactive components from framework-specific implementations into portable, well-tested state machines that work seamlessly across your entire technology stack. Our web development services team has successfully implemented state machine-based architectures for organizations seeking to streamline their component libraries and reduce maintenance overhead.
The Design System Dilemma: When Good Systems Go Bad
The promise of design systems is compelling: create a component once, reuse it everywhere, and maintain consistency across your entire application. However, many teams discover that this promise falls apart when they need to support multiple frameworks or when their interactive components grow in complexity. The same dropdown menu might need a React version, a Vue version, and a Solid version--each requiring separate implementation, testing, and maintenance.
This duplication creates significant overhead. When a critical accessibility bug is discovered in your combobox component, fixing it means updating three separate codebases, running three separate test suites, and coordinating three separate deployments. The cognitive load of remembering which behavior exists in which framework adds up quickly, and subtle inconsistencies creep in as different developers make slightly different decisions across implementations.
The problem compounds when you consider that seemingly simple components often share significant logic with each other. An accordion and a collapsible panel have nearly identical behavior--just triggered from different directions. A date picker and a time picker both need calendar or list navigation. Rather than building a reusable foundation, teams often find themselves reinventing these patterns repeatedly, accumulating technical debt with every new component they add.
The Hidden Complexity in Interactive Components
What appears to be a simple dropdown menu actually contains remarkable complexity beneath the surface. Users expect seamless keyboard navigation--arrow keys to move between options, Home and End to jump to the beginning or end of the list, Enter to select, Escape to close. Focus must be managed carefully: when the dropdown opens, focus should move appropriately; when it closes, focus should return to the trigger element. Roving tabindex means only one item in the list is focusable at a time, with arrow keys shifting that focus.
The ARIA attributes tell an equally complex story. A combobox needs aria-expanded on the trigger, aria-controls linking to the listbox, aria-activedescendant for the currently highlighted item, and aria-selected for the chosen items. These attributes must change dynamically as the user interacts with the component, and they must be perfectly synchronized with the visual state.
Edge cases abound. What happens when a user clicks rapidly on the trigger, opening and closing the dropdown? What if the options load asynchronously from an API? What if the user tabs away while the dropdown is open--where should focus go? What if an option is removed from the data source while it's selected? Each of these scenarios requires careful consideration, and implementing all of them correctly in every framework you support is a monumental task.
This complexity is precisely why design system teams often struggle to deliver on their promises. The time required to implement one component correctly across multiple frameworks is substantial, and the maintenance burden only grows as your component library expands. A better approach is needed--one that captures this complexity once and reuses it everywhere.
Enter State Machines: A Better Model for UI Components
State machines have been used successfully in safety-critical systems for decades--from aviation controls to industrial manufacturing equipment--because they provide a rigorous way to model complex behavior. A state machine defines all possible states a system can be in, all events that can trigger transitions between states, and what happens during those transitions. This explicit modeling eliminates the "implicit knowledge" problem where behavior exists in code but isn't documented anywhere.
For UI components, this approach is transformative. Instead of scattered useState calls and useEffect hooks that may or may not interact correctly, a state machine makes every possible state visible: open, closed, loading, error, focused, selected. Every user action becomes an event that triggers a well-defined transition. The result is component behavior that can be understood by reading the machine definition alone--no need to trace through effects and event handlers to understand what will happen in any given situation.
Finite state machines (FSM) form the foundation, but statecharts extend this concept to handle more complex scenarios: hierarchical states (a dropdown can be "open" while also being "loading"), parallel states (a date picker showing both a calendar and a year selector simultaneously), and guarded transitions (you can only select an item if the dropdown is open). These extensions make statecharts powerful enough to model even the most complex UI components while remaining understandable and maintainable.
The testability benefits are substantial. Because the machine definition is independent of any framework, you can test the complete behavior of your component by simply sending events and asserting on the resulting state. This means you can verify that your combobox handles keyboard navigation correctly without needing to render it in a browser or set up a testing library.
1// Traditional useState approach2function Toggle() {3 const [isOn, setIsOn] = useState(false)4 5 return (6 <button7 onClick={() => setIsOn(!isOn)}8 aria-pressed={isOn}9 >10 {isOn ? 'ON' : 'OFF'}11 </button>12 )13}14 15// State machine approach with Zag16const toggleMachine = createMachine({17 id: 'toggle',18 initial: 'off',19 states: {20 off: {21 on: { TOGGLE: 'on' }22 },23 on: {24 on: { TOGGLE: 'off' }25 }26 }27})Zag.js: State Machines for Production UI
Zag.js emerged from the real-world experiences of the Chakra UI team, who faced the same design system challenges that many teams encounter. After maintaining multiple framework implementations of their components, they sought a better way--and state machines provided the answer. Zag is their solution: a framework-agnostic toolkit for implementing production-ready UI components using the state machine approach.
What makes Zag different is its commitment to framework independence. The same combobox machine that works in React also works in Vue and Solid, simply by swapping the adapter. This means you write the complex component logic once, and it works everywhere. The maintenance burden that once scaled linearly with the number of frameworks you support now becomes constant--no matter how many frameworks you target, the core behavior is shared.
The headless design philosophy means Zag provides no styling whatsoever. Instead, it focuses purely on behavior: states, events, transitions, and accessibility. You bring your own design system--whether that's CSS Modules, Tailwind, styled-components, or any other solution. This separation of concerns makes your components more portable and ensures your design system maintains full control over appearance.
With 40+ pre-built component machines covering everything from accordions to tabs to date pickers, you can often find a ready-made solution for common interactive patterns. Each component is published as a separate NPM package, meaning you can adopt Zag incrementally--install just the combobox machine for now, add more components as you need them.
State Machine Foundation
Built on modern statechart concepts for robust behavior modeling
Framework Agnostic
One implementation works across React, Vue, and Solid
Accessibility First
WAI-ARIA patterns built into every machine
Headless Design
Complete styling freedom with no opinion on appearance
Incremental Adoption
Install only the components you need
Building Your First Zag Component
Let's walk through creating a reusable accordion component with Zag. This example demonstrates the complete workflow: installing the machine, defining component behavior, and connecting it to your UI framework. By the end of this section, you'll understand how to build any Zag component--not just accordions, but any of the 40+ patterns in the library.
The accordion pattern is an excellent starting point because it captures the essential elements of Zag without overwhelming complexity. An accordion has clear states (open or closed for each item), clear events (clicking a trigger), and clear accessibility requirements. Yet it also demonstrates how Zag handles composition when multiple items are involved.
Our web development services include design system architecture and implementation, helping organizations adopt modern component patterns like state machines to reduce maintenance and improve consistency across their applications.
1npm install @zag-js/accordion @zag-js/reactStep 2: Define Component States and Events
Before writing any UI code, you model the component's behavior as a state machine. This involves identifying all possible states, the events that trigger transitions between them, and any context data the machine needs to track. For an accordion, each item can be in an "open" or "closed" state, and the TOGGLE event moves between them.
The context object holds data that varies across instances--in this case, the items being controlled and which indexes are currently open. Guards allow you to conditionally prevent transitions, useful for scenarios like preventing a section from opening until data has loaded. Actions run side effects during transitions, such as updating the open indexes array.
This machine definition is entirely independent of React, Vue, or any framework. It describes the accordion's behavior in a pure, testable format. You can verify that clicking an open item closes it and clicking a closed item opens it--all without rendering anything to the DOM.
1import { createMachine } from '@zag-js/core'2 3const accordionMachine = createMachine({4 id: 'accordion',5 initial: 'closed',6 context: {7 items: [],8 openIndexes: []9 },10 states: {11 closed: {12 on: {13 TOGGLE: {14 target: 'open',15 actions: ['setOpenItem']16 }17 }18 },19 open: {20 on: {21 TOGGLE: {22 target: 'closed',23 actions: ['clearOpenItems']24 }25 }26 }27 }28})Accessibility: Built-In, Not Bolted-On
Accessibility is often treated as an afterthought in component development--a box to check after the "real" work is done. This approach leads to inconsistent support and expensive retrofits when issues are discovered. Zag takes a fundamentally different approach: accessibility is built into every machine from the ground up, based on the official WAI-ARIA Authoring Practices.
Each Zag component machine implements the appropriate ARIA patterns for its type. A combobox follows the combobox pattern, implementing aria-expanded, aria-controls, aria-activedescendant, and all the other attributes required by the specification. A dialog implements focus trapping and aria-modal. A tree view implements the tree pattern with proper keyboard navigation. These implementations aren't optional add-ons--they're integral to how the machines work.
Keyboard navigation support comes automatically. Arrow keys, Home, End, Escape, Enter, Space--each component handles these according to its pattern. For complex patterns like comboboxes, Zag implements the "virtual focus" pattern where visual focus is separate from DOM focus, allowing keyboard-only users to navigate long lists efficiently.
Focus management is similarly comprehensive. When a dialog opens, focus moves to the first focusable element and is trapped within the dialog until it closes. When it closes, focus returns to the element that opened it. For nested dialogs or complex overlays, the focus trap correctly handles all scenarios without requiring custom code.
The practical result is that accessibility compliance comes for free when you use Zag components. You don't need to remember which ARIA attributes apply or which keyboard shortcuts to implement--you get correct behavior out of the box, with full compatibility with screen readers like NVDA, VoiceOver, and JAWS.
Roving Tabindex
For component trees, nested menus, and hierarchical navigation
Focus Trapping
For dialogs and modals to keep focus within the element
Virtual Focus
For comboboxes and listboxes with keyboard navigation
ARIA Attributes
Automatic aria-expanded, aria-selected, and other attributes
From One Component to a Full Design System
Building a single accessible accordion with Zag is just the beginning. The real power emerges when you scale this approach across your entire design system. With Zag as your foundation, you can build a cohesive component library that maintains consistency across all your applications--regardless of which framework each application uses.
The key to successful scaling is creating wrapper components that match your design system's API conventions. Instead of using Zag hooks directly throughout your application, you create components like <Accordion>, <AccordionItem>, and <AccordionTrigger> that wrap the underlying machine. These wrappers encapsulate your design system's prop naming conventions, default behaviors, and styling patterns. The Zag machine handles the complex behavior; your wrapper handles the presentation.
Configuration management becomes straightforward when you're working with machines. Common patterns across components--like allowing multiple items to be open simultaneously or requiring exactly one item to be open--can be exposed through consistent configuration options. Because these configurations affect the machine's behavior rather than the UI, you get consistent semantics across all your components.
Sharing machines between applications transforms maintenance from a multi-framework burden into a single responsibility. When you discover an edge case in your combobox behavior, you fix it in the machine definition once, and every application using that machine gets the fix. Testing happens once, on the machine itself, rather than requiring separate test suites for each framework. The efficiency gains compound as your component library grows.
For organizations with mixed-technology stacks, this cross-framework sharing is transformative. A company running React for their marketing site, Vue for their internal tools, and Solid for their high-performance dashboard can share the exact same component logic across all three. The design system team maintains one codebase; the application teams consume one package. There's no duplication, no drift, and no coordination overhead.
Best Practices for Zag-Powered Design Systems
Adopting Zag successfully requires embracing the state machine model rather than fighting it. Teams that try to replicate their existing imperative patterns within Zag often struggle, while those that embrace the declarative model find it liberating. The machine defines behavior; the UI reflects that state. This separation of concerns leads to more maintainable code and fewer bugs.
Keep your UI layers thin. The UI should read state from the machine and dispatch events--it shouldn't contain business logic or complex state management. When you find yourself writing substantial code in your component files, consider whether that logic belongs in the machine instead. This separation makes the machine more testable and your UI simpler.
Test the machine independently of the UI. Because the machine definition is framework-agnostic, you can verify complete behavior with simple event dispatching and state assertions. This testing is fast, reliable, and doesn't require browser rendering. Your UI tests can focus on styling and integration, while the machine tests verify behavior.
Document state diagrams for your team. The machine definition is documentation, but a visual diagram can help team members unfamiliar with state machines understand the component's behavior quickly. Tools exist to generate these diagrams from machine definitions automatically. This investment in documentation pays dividends when onboarding new team members or debugging complex behavior.
TypeScript provides significant benefits with Zag. The type definitions for machine context, events, and state make it clear what data the machine expects and what events it responds to. autocomplete for event types and state checking catches errors at compile time rather than runtime. For any team building a production design system, TypeScript with Zag is the recommended approach.
Frequently Asked Questions
Conclusion
Design systems should reduce maintenance burden, not increase it. When the same component needs to work across multiple frameworks, traditional approaches multiply effort and introduce inconsistency. Zag.js offers a fundamentally different model: capture component behavior once as a state machine, then use that machine wherever you need it.
The benefits compound over time. Every new component you build with Zag is immediately available to every application in your organization, regardless of framework. Every bug fix applies everywhere. Every accessibility improvement reaches all your users. The initial investment in learning state machines pays dividends throughout your design system's lifetime.
Accessibility compliance becomes automatic rather than aspirational. The WAI-ARIA patterns that Zag implements have been battle-tested across countless applications and refined based on real-world feedback. When you use Zag, you're building on this accumulated knowledge rather than reinventing it.
Start small. Pick one complex component in your design system--a combobox, a date picker, an accordion with multiple open items--and implement it with Zag. Experience how the state machine model makes complex behavior manageable. Then expand from there, component by component, until your entire design system benefits from the DRY, testable, accessible foundation that Zag provides. Our web development services team can help you evaluate your current design system and develop a migration strategy that works for your organization.