Renderers
What a conformant Polyxd renderer must do, the two shipped renderers (React and Web Components), the framework-free core they share, the Vue and Svelte adapters, how to run the conformance suite, and native renderers as the next step.
A UI document says what an interface means; a renderer turns it into your platform's components. Polyxd ships two, built on one framework-free core, and a conformance suite that holds any renderer to the same contract: the same components in the same order, the same roles and names, the same text, the same findings, the same agent tasks passing.
@polyxd/core decisions, bindings, formatting, the headless surface
/ \
@polyxd/react @polyxd/web React 19 + Radix Web Components, no framework
\ /
@polyxd/verifier conformance: both renderers, every document, 13 packs × 2 × 2
Not React?#
@polyxd/web renders a document as a custom element with no framework and no shadow DOM:
<link rel="stylesheet" href="node_modules/@polyxd/web/styles.css" />
<link rel="stylesheet" href="node_modules/@polyxd/web/themes/carbon.css" />
<polyxd-surface id="send" theme="carbon" mode="light"></polyxd-surface>
<script type="module">
import { defineElements } from "@polyxd/web";
defineElements();
const el = document.getElementById("send");
el.document = doc;
el.data = { quote };
el.addEventListener("polyxd-action", ({ detail }) => {
if (detail.name === "transfer.confirm") sendMoney(detail.context.quoteId);
});
</script>
document, data, resolveMedia, derive and components are properties; theme, mode, density, locale and disclosure are attributes as well. The element dispatches polyxd-action (the ActionEvent), polyxd-datachange (the new data) and polyxd-dismiss. <polyxd-frame> renders a shell document around the host's screen, with current and loading like PolyxdFrame. For a DOM owned by something else, mount(el, props) renders without the elements. Without a bundler, preview/polyxd-web.js and preview/polyxd-web.css are a self-contained build.
The DOM it produces is the React renderer's: the same pxd-* classes, the same ARIA, so the stylesheet and every theme apply unchanged, and so does everything the verifier checks. Overlays are native <dialog> elements shown modally; menus, comboboxes, tabs, radios, switches and sliders are written for the DOM with the roles and keyboard handling Radix gives React.
Vue and Svelte#
Two adapters ship as files to copy, in @polyxd/web's adapters/ folder: PolyxdSurface.vue (Vue 3, <script setup>) and PolyxdSurface.svelte (Svelte 5 runes). Each takes the same props, forwards the element's events as action, datachange and dismiss, and stays a few dozen lines because the element does the work. They are documented, not published.
The core#
@polyxd/core is what every renderer shares and what a new one starts from: no DOM, no framework, no dependencies.
| Area | What is there |
|---|---|
| Document | The types (UIDocument, Node, Action, ActionEvent, FrameLayout, NavigationPlacement), the component list COMPONENTS, the renderer's own action names RENDERER_ACTIONS, indexById, mainNavigation |
| Bindings | JSON Pointer get and immutable set; resolve, resolveContext, resolveDeep, absolute, childPointer, asList, isBinding, itemScopes, ROOT_SCOPE |
| Formatting | formatValue (numbers, currency, percent, dates, times, relative time, duration, bytes, colours, per locale), resolveFormat, safeColor, currencySymbol, formatCount, formatPercent |
| The headless surface | createSurface(document, { data, locale, derive, onAction, onDataChange, onDismiss }) returns { byId, data, setValue, replaceData, dispatch, subscribe, resolve, text, pointer, write, context, visible }; dispatchAction (ui.dismiss to the host's onDismiss, everything else to onAction with its context resolved), contextWithValue, copyText, rowChangeAction, isRendererAction, a11yAttributes |
| Choice | optionsOf, planChoice (chips for a few short options, a people picker for faces, otherwise a list, searchable past ten), optionKey, matchesQuery, partitionRecent, toggleSelection, isSelected, searchPlaceholder, idOf, idGenerator, CHIPS_MAX, CHIP_LABEL_MAX, SEARCHABLE_PAST |
| State machines | Steps: stepsReducer, initialStep, isLastStep, stepsProgress, TASK_STATUS, taskStatus, tasklistReducer, tasklistProgress. Views: selectedView, viewsReducer. Split: splitReducer, initialSplit, splitPanes, splitSelection, splitItemValue, clampShare, SPLIT_COMPACT_PX, SHARE, SHARE_MIN, SHARE_MAX |
| Layout rules | Frame: frameWidth, placementFor, appBarTitle, documentTitle, WIDE_PX, MEDIUM_PX, BAR_MAX, NAV_COMPACT_PX. Table: TABLE_COMPACT_PX, PAGE_SIZES, isNumericColumn, stackedColumns, rowValue, rowScopes, toggleValue, nextSort, columnCount, paging. ActionBar: fitActions, minShown, menuOrder, MORE_ACTIONS, TRIGGER_FALLBACK. Collection: collectionItemValue, collectionLayout, orderedIndices, moveItem, dateParts, monthToShow, shiftMonth, calendarMonth, nearestSlide. Tree: treeRows, treeKey, typeAheadTarget, visibleWindow, VIRTUAL_LIMIT, OVERSCAN, TYPEAHEAD_MS. FilterPanel: activeFilters, resultCountText, FILTER_COMPACT_PX. Comparison and Navigation: bestPerAttribute, groupAttributes, recommendedFirst, groupItems, navigationLabel |
| Loading | skeletonShape, SKELETON_SHAPES, SKELETON_GROUP_CLASS, PATTERN_SHAPE, skeletonStatus |
| Shortcuts | parseShortcut, shortcutMatches, unmodified, isApplePlatform, SHORTCUT_KEYS, MODIFIER_FLAGS |
| Small marks | metricChange, gaugeState, meterHint, starsLabel, ratingSaid, maskSecret, groupSummary, IDENTITY_SIZE, avatarTone, initialsOf, isMediaRef, iconPath, ICON_PATHS, STATUS_ICON, STAR_PATH |
| Content | richText (tokens for bold, italic, code and safe links), qrEncode, chart geometry (niceMax, axisLabel, treemap, verticalScale, flowLayout, markerShape, seriesColor, CHART_W, CHART_H, CHART_PAD, MARKERS), masks and numeric fields (applyMask, maskIsNumeric, numericValue, storedText, addTag), colours (parseColor, formatColor, toHex6, sameColor, colorPlaceholder, hslToRgb, rgbToHsl, clamp), file limits (formatBytes, describeType, listText, matchesAccept, fileLimits, refuseFile) |
@polyxd/react imports all of it; its public API and its rendered output did not change. @polyxd/web exports PolyxdSurfaceElement, PolyxdFrameElement, defineElements, mount, Renderer, registry (its 44 component renderers, to wrap or replace), Skeleton, and the small virtual-DOM it draws with (h, adopt, render, unmount), plus formatValue, getPointer and setPointer from core for symmetry with the React package.
What a conformant renderer must do#
The contract is on the rendered page, not on a component API, so it holds for any framework. In full in the verifier's harness/README.md; in short:
- Read
window.__PXD__ = { document, theme, mode }and render the document into#root, with the outer element carryingclass="pxd-surface",data-pxd-themeanddata-pxd-mode, and every component's element carryingdata-pxd-idanddata-pxd-component. Overlays render inside the surface. A shell renders around a stand-in screen (<h1>Screen</h1>). Media references resolve to a placeholder. - Record what it sends: push every
ActionEventontowindow.__pxdActions; countui.dismissinwindow.__pxdDismissed. - Set
window.__pxdReady = trueafter the first paint. The verifier waits for finite animations to end before it measures. - Make the same decisions. A
Choicewith three short options is chips, aTablestacks below 720px, aFrameputs five items in a bar on a phone,ui.dismisscloses, an input's action carries the value it just wrote. All of these are in@polyxd/core; use it. - Give the same accessibility tree. The roles, accessible names, states and reading order the component definitions require, which the React renderer's output defines in practice: a
Confirmis analertdialogwhose consequence sits directly above its buttons, a rating is a radiogroup of "1 star" to "5 stars", a table's stacked rows keep the Select button an agent presses at any width.
Then run the suite against it:
import { verifyDocument, HARNESSES } from "@polyxd/verifier";
const report = await verifyDocument(doc, { harness: { name: "mine", html: "./my-harness/index.html" }, fingerprint: true });
harness is { url } for a page you serve or { html } for a directory of static files (served from disk, nothing listens on a port). fingerprint: true records, per target, the components in order, the ARIA snapshot and the visible text; compareFingerprints(a, b) explains any difference in words.
Running the conformance suite#
npm run conformance -w @polyxd/verifier
Renders every spec example and every demo document (apps/demos/*/{intents,authored}/*.json) with both shipped renderers in 13 packs × light/dark × 390/1100 and compares the fingerprint, the axe and layout findings, and the agent tasks. Both renderers must score 100 and every fingerprint must match; the report is per document and per renderer, and any difference is printed in words (aria differs at line 12: react "button 'Send money'" vs web "button 'Send'"). Options: --only <name>, --packs a,b, --modes light, --widths 390, --concurrency 6, --json report.json.
The result today: 90 documents, 4,680 renders per renderer, both at 100 everywhere, fingerprints identical in all 90. Building it found and fixed the differences you would expect between a Radix primitive and a hand-written one (a false aria-checked dropped instead of stated, a popover anchor counted as a component) and one the React harness had been getting away with: an entrance fade that axe could catch mid-way, now waited out for every renderer.
Native renderers: the next step#
Each component definition carries a per-platform mapping, so a native renderer walks the same tree with the same core and draws with the platform's own components. Examples from the spec:
| Component | iOS | Android |
|---|---|---|
Choice |
Picker (.segmented, .inline or .menu) or a multi-select List |
SegmentedButton, RadioButton rows, ExposedDropdownMenuBox or Checkbox rows |
Table |
Table on regular width; List of rows on compact width |
LazyColumn of rows with a header row |
Confirm |
.confirmationDialog / .alert with role: .destructive |
AlertDialog |
Steps |
NavigationStack pushes or a paged view with a ProgressView |
A stepper row with content and a LinearProgressIndicator |
Navigation |
TabView, or a sidebar on iPad |
NavigationRail / NavigationDrawer |
Frame |
NavigationSplitView / TabView with a NavigationStack |
Scaffold with TopAppBar, NavigationRail or NavigationBar, and content |
AppBar |
Navigation bar with toolbar items | TopAppBar |
Outlet |
NavigationStack content |
NavHost |
What such a renderer reuses is everything in @polyxd/core (the decisions, the bindings, the formatting, createSurface), so what it writes is the drawing. What it must still meet is the contract above; the practical way to audit it is to render the native view for a document into a WebView-hosted harness page, or to compare its accessibility tree against the ARIA snapshot the suite records, which is why the fingerprint is roles, names and text rather than DOM.
Custom renderers inside React#
Replacing one component's renderer inside @polyxd/react is a different, smaller thing: the components prop. See Custom renderers. The same prop carries a host's own components for a shell's Custom; <polyxd-surface>'s components property does both jobs for the Web renderer.
Polyxd is an early preview. Found something unclear? It will get better with your feedback.