Skip to main content

Event composition

Zag encourages spreading props from connect onto elements, which automatically attaches all necessary event handlers and ARIA attributes. Sometimes you also need to attach your own event handlers to the same element. The mergeProps utility handles this correctly — it merges event handlers so that both the machine’s handler and your custom handler run, and combines class names and other attributes safely.
import { useMachine, mergeProps } from "@zag-js/react"
import * as hoverCard from "@zag-js/hover-card"
import { useId } from "react"

export function HoverCard() {
  const service = useMachine(hoverCard.machine, { id: useId() })
  const api = hoverCard.connect(service)

  // Your custom handler
  const handleHover = () => {
    console.log("hovered")
  }

  // Merge machine props with your own
  const triggerProps = mergeProps(api.getTriggerProps(), {
    onPointerEnter: handleHover,
  })

  return (
    <a href="https://twitter.com/zag_js" target="_blank" {...triggerProps}>
      {api.open ? "Open" : "Close"}
    </a>
  )
}
Do not manually override event handlers by listing them after the spread (e.g. {...api.getTriggerProps()} onPointerEnter={...}). That would silently discard the machine’s handler. Always use mergeProps.

ID composition

Zag relies on pure DOM queries to locate elements within a component. Every machine instance needs a unique id to ensure its internal element lookups do not conflict with other instances on the page. Pass the id directly to useMachine:
import * as accordion from "@zag-js/accordion"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"

function Accordion() {
  const service = useMachine(accordion.machine, { id: useId() })
  const api = accordion.connect(service, normalizeProps)
  // ...
}
See useId.

Composing two machines on one element

In some cases you want a single element to act as the trigger for two machines at the same time — for example, a button that opens both a tooltip and a popover. Zag locates elements by their generated IDs. To share a trigger element between two machines, pass the same custom ID to both via the ids option:
import * as tooltip from "@zag-js/tooltip"
import * as popover from "@zag-js/popover"
import { useMachine, mergeProps, normalizeProps } from "@zag-js/react"

function Example() {
  const tooltipService = useMachine(tooltip.machine, {
    ids: { trigger: "shared-trigger" },
  })

  const popoverService = useMachine(popover.machine, {
    ids: { trigger: "shared-trigger" },
  })

  const tooltipApi = tooltip.connect(tooltipService, normalizeProps)
  const popoverApi = popover.connect(popoverService, normalizeProps)

  // Merge trigger props from both machines
  const triggerProps = mergeProps(
    tooltipApi.getTriggerProps(),
    popoverApi.getTriggerProps(),
  )

  return <button {...triggerProps}>Open both</button>
}

Customizing element IDs

When you need to override the ID that Zag generates for a specific part, always use the ids option in the machine context — never set id directly on the element via spread props.
// Correct: use the ids option
const service = useMachine(checkbox.machine, {
  ids: { label: "my-custom-label" },
})
const api = checkbox.connect(service, normalizeProps)
return <label {...api.getLabelProps()}>Label</label>
Why this matters: Zag generates ARIA reference attributes such as aria-labelledby and aria-describedby based on the IDs it knows about. If you override IDs manually on the element, the machine cannot update the corresponding ARIA attributes on related elements, which breaks accessibility.

Avoid configuring IDs for elements you do not render

When you set a custom ID for a part, Zag adds ARIA references pointing to that ID — even if the element is never rendered. This creates dangling ARIA references that break screen reader output.
// Incorrect: label ID configured but label never rendered
const service = useMachine(checkbox.machine, {
  ids: { label: "custom-label-id" },
})
// aria-labelledby="custom-label-id" is added to the control,
// but there is no element with that ID in the DOM

// Correct: omit the ID if you are not rendering the label
const service = useMachine(checkbox.machine)
return (
  <div {...api.getControlProps()} aria-label="Accept terms">
    <input {...api.getHiddenInputProps()} />
  </div>
)

Custom window environments

Zag uses document.querySelectorAll and document.getElementById internally. In non-standard environments — iframes, Shadow DOM, Electron — the global document may not be the right root node. Pass a getRootNode function to the machine context to provide the correct reference:
import * as accordion from "@zag-js/accordion"
import { useMachine, normalizeProps } from "@zag-js/react"
import Frame, { useFrame } from "react-frame-component"

function Accordion({ id }) {
  const { document } = useFrame()

  const service = useMachine(accordion.machine, {
    id,
    getRootNode: () => document,
  })

  const api = accordion.connect(service, normalizeProps)

  return (
    <div {...api.getRootProps()}>
      {["Watercraft", "Automobiles", "Aircraft"].map((item) => (
        <div key={item} {...api.getItemProps({ value: item })}>
          <h3>
            <button {...api.getTriggerProps({ value: item })}>{item}</button>
          </h3>
          <div {...api.getContentProps({ value: item })}>
            Sample accordion content
          </div>
        </div>
      ))}
    </div>
  )
}

export default function App() {
  return (
    <Frame height="200px" width="100%">
      <Accordion id="accordion-in-frame" />
    </Frame>
  )
}
In Shadow DOM, you can derive the root node from any element already inside the shadow tree by calling element.getRootNode().

Build docs developers (and LLMs) love