Adapters
An adapter turns code written for another model into a Sygnal widget tag: you render it with a class, read its events in the intent with .detail(), and send it element commands, as with any widget.
fromZag(sygnal/zag) runs a Zag.js state machine and renders its parts with Sygnal JSX. It is what Menu, Select and Combobox are built on.fromReact(sygnal/react) renders a React component, or a Preact one throughpreact/compat.
Adapters are an escape hatch. Reach for one for the component you can’t replace, not as a way to build the app. First look for a native answer (<dialog>, the Popover API, sygnal/ui), a web component, or a framework-agnostic library through defineWidget. A React component in a Sygnal app:
- brings its runtime: React and react-dom add about 68 KB gzipped (Preact through
preact/compatabout 8 KB); - doesn’t see your context or providers: React context, a theme provider, a router or a query client from a React tree don’t cross the boundary, and Sygnal’s
.contextdoesn’t reach the component. Pass what it needs as props; - can render outside its host: a React portal (a modal, a tooltip) goes where React puts it, so Sygnal’s
DOM.selectdoesn’t see events from it; use the component’s callbacks instead; - doesn’t run in the mock DOM: test it with
renderComponent(App, { dom: 'real' })(jsdom or a browser).
The libraries are optional peer dependencies of sygnal: an app that doesn’t import an adapter never installs or ships them. When one is missing, sygnal/vite names it (SYG666).
React components: fromReact
Section titled “React components: fromReact”npm install react react-domThe React component here is a small one of our own, written with React’s createElement (the JSX on these pages is Sygnal’s); a component from npm is wrapped the same way:
// a React componentimport { createElement as h } from 'react'
export function Rating({ value, max, onChange }) { return h('span', { role: 'group', 'aria-label': 'Rating' }, Array.from({ length: max }, (_, i) => h('button', { key: i, type: 'button', 'aria-label': `${i + 1} of ${max}`, 'aria-pressed': i < value, onClick: () => onChange(i + 1), }, i < value ? '★' : '☆')))}import { fromReact } from 'sygnal/react'import { Rating } from './Rating.js'
export const Stars = fromReact(Rating, { events: { rate: 'onChange' } })import { Stars } from './Stars.js'
export function Review({ state }) { return ( <div> <Stars className="rating" value={state.rating} max={5} /> <p>{state.rating} of 5</p> </div> )}
Review.initialState = { rating: 0 }Review.intent = ({ DOM }) => ({ RATE: DOM.select('.rating').events('rate').detail(),})Review.model = { RATE: (state, rating) => ({ ...state, rating }),}The tag renders a host <div class="rating"> and a React root inside it. Every render passes the newest props to the component (value, max); the props are routed between the host and the component:
| Props | Go to |
|---|---|
className, class, id, style, attrs, tabIndex, hidden | the host only (one tab stop) |
aria-*, role, title | the component only: a React control names itself, so <IconButton aria-label="Delete" /> labels the component’s <button>, not the generic host <div> |
data-* | both |
| everything else | the component (name, placeholder, lang and dir also go on the host, as for any widget) |
List a host prop in ownProps to send it to the component instead, or a component-only one in hostProps to put it on the host too. A React component that doesn’t pass aria-* and role on to its element (it takes only the props it names) gets no accessible name this way: fix the component to spread them, or list them in hostProps (hostProps: ['role', 'aria-label']) so the host carries the role and the name. When the component calls onChange(4), the adapter dispatches a rate event on the host with 4 as its detail.
When the host leaves the page (also inside a shadow root, as with sygnal/element’s shadow: true), the React root unmounts. A host can also be destroyed while its element stays in the page, for example during a <Transition> leave that never gets its transitionend. The React root then stays mounted until the element is removed, and for at most 10 seconds, so no root or observer is leaked. A component’s cleanup (useEffect returns) can therefore run up to 10 seconds after the host was destroyed.
| Option | |
|---|---|
events | { eventName: 'onCallback' }: each callback prop becomes a DOM event with that name. An array (['onChange']) uses the callback’s own name as the event name. The detail is the callback’s argument, or an array of them when it gets several. A callback you pass as a prop still runs, first |
props | (props) => componentProps, when the component’s props differ from the tag’s |
commands | Element commands, called with { root, el, props } |
tag, name, fallback, hostProps, ownProps | As in defineWidget |
Prefer event names of your own ('rate') to the browser’s ('change'): the component’s own <input>s fire native change events that bubble out of the host too.
Preact
Section titled “Preact”The adapter uses only createElement (from react), createRoot (from react-dom/client) and flushSync (from react-dom), which preact/compat provides. Alias the React packages in the bundler, and the same adapter and the same component code run on Preact:
import { defineConfig } from 'vite'import sygnal from 'sygnal/vite'
export default defineConfig({ plugins: [sygnal()], resolve: { alias: { react: 'preact/compat', 'react-dom/client': 'preact/compat/client', 'react-dom': 'preact/compat', }, },})npm install preactZag machines: fromZag
Section titled “Zag machines: fromZag”Zag.js implements UI patterns (menus, comboboxes, date pickers, sliders) as framework-agnostic state machines, with the keyboard and ARIA work done. fromZag(zag, render, options) runs one on a widget host:
npm install @zag-js/vanilla@~1.45.0 @zag-js/menu@~1.45.0import { fromZag } from 'sygnal/zag'import * as menu from '@zag-js/menu'
export const Actions = fromZag(menu, (api, props) => ( <div> <button {...api.getTriggerProps()}>{props.label}</button> <div {...api.getPositionerProps()}> <ul {...api.getContentProps()}> {props.items.map((item) => <li {...api.getItemProps({ value: item })}>{item}</li>)} </ul> </div> </div>), { name: 'Actions', events: { pick: ['onSelect', (details) => details.value] }, commands: { open: (api) => api.setOpen(true) },})import { Actions } from './Actions.jsx'
export function Toolbar({ state }) { return <div><Actions className="actions" label="Edit" items={['cut', 'copy', 'paste']} /><p>{state.last}</p></div>}
Toolbar.initialState = { last: '' }Toolbar.intent = ({ DOM }) => ({ PICK: DOM.select('.actions').events('pick').detail(),})Toolbar.model = { PICK: (state, last) => ({ ...state, last }),}Zag’s parts come unstyled. Style them by the data-scope and data-part attributes the prop getters put on them:
[data-scope="menu"][data-part="content"] { margin: 0; padding: 0.25rem 0; list-style: none; background: Canvas; border: 1px solid GrayText; z-index: 1; }[data-scope="menu"][data-part="item"] { padding: 0.25rem 0.75rem; cursor: pointer; }[data-scope="menu"][data-part="item"][data-highlighted] { background: Highlight; color: HighlightText; }zagis the machine package as a namespace (import * as menu from '@zag-js/menu'): itsmachineandconnect.render(api, props, instance)returns one element.apiis the machine’s connected API: spread its prop getters on your elements ({...api.getTriggerProps()}); they carry the ARIA attributes, the ids and the event handlers. It runs again whenever the machine’s state changes and when the tag gets new props, and the result is patched into the host. Render plain elements only: the adapter patches the result itself, outside the component tree, so a Sygnal component, a widget tag or special JSX (<Transition>,<Portal>,<Collection>…) inside it doesn’t run. Pass data in through the tag’s props, and keep components and widgets around the tag in the view (SYG669 in dev).- Props go to the machine as they are (
open,value,positioning…), always the newest ones: a controlled prop such asopen={state.menuOpen}drives the machine.options.props(props, instance)maps them when they differ (Select turnsvalue: 'm'into Zag’s['m']). events:{ eventName: 'onCallback' }dispatches the callback’s details object;{ eventName: ['onCallback', (details) => detail] }dispatches what the function returns.commands:{ name: (api, options, instance) => … }forELEMENT.
Pin Zag’s version (~1.45.0, all packages the same): the adapter uses @zag-js/vanilla’s machine runtime, whose API changes in Zag 2.0.
Testing
Section titled “Testing”In the default mock DOM, an adapter’s tag renders its host and nothing runs inside it. t.widget(selector) gives the props the view passed and dispatches the adapter’s events, so the component’s logic can be tested without React or Zag:
import { it, expect } from 'vitest'import { renderComponent } from 'sygnal'import { Review } from './Review.jsx'
it('stores the rating', async () => { const t = renderComponent(Review) await t.ready() t.widget('.rating').dispatch('rate', 4) await t.next((state) => state.rating === 4) expect(t.widget('.rating').props.value).toBe(4) t.dispose()})To run the React component or the machine itself, use renderComponent(Review, { dom: 'real' }) (in a jsdom test environment), click with t.query(...), and wait for the state with t.next. Zag’s positioning and list scrolling use ResizeObserver, CSS.escape and Element.prototype.scrollTo, which jsdom lacks: while a dom: 'real' test runs, renderComponent adds the missing ones (a ResizeObserver that observes nothing, a scrollTo that does nothing) and removes them when the last test instance is disposed. An implementation the environment already has is kept. Outside renderComponent (a test that calls run() itself), stub them in a setup file.
Server rendering
Section titled “Server rendering”renderToString renders an adapter’s host with its fallback inside, like any widget; React and Zag run only in the browser. The client’s first render replaces the fallback.
Gzipped, added to a small app:
| Adds | |
|---|---|
fromReact with React 19 + react-dom | 68 KB (the adapter itself: 0.6 KB) |
fromReact with preact/compat | 8 KB |
fromZag with Zag’s dialog machine | 20 KB (the adapter itself: 2 KB) |
| Menu, Select, Combobox | 33–34 KB each, 47 KB for the three |
Diagnostics
Section titled “Diagnostics”| Code | When |
|---|---|
| SYG666 | An adapter entry is imported, but a package it needs isn’t installed (sygnal/vite) |
| SYG667 | fromZag or fromReact got something that isn’t a machine package or a component |
| SYG669 | A fromZag render returned a Sygnal component, a widget tag or special JSX, which can’t run there (dev) |
The widget codes (SYG140–144, SYG660–662) apply to adapters as to any widget.