Generating Framework Wrappers Instead of Writing Them
One Lit source, six framework bindings. Why Prism generates ARC UI's React, Vue, Svelte, Angular, Solid, and Preact packages rather than maintaining them by hand.
Every web component library that wants React users eventually writes a React wrapper. The trap is writing it once, by hand, and then letting it rot. That usually isn’t negligence: a hand-maintained wrapper is a second codebase wearing the first one’s clothes, and second codebases drift from the thing they’re supposed to mirror.
ARC UI is ~160 components built in Lit as Web Components: shadow DOM, slot-based composition, design tokens as CSS custom properties. That’s the source of truth. The React, Vue, Svelte, Angular, Solid, and Preact bindings are all downstream of it. I don’t write those packages. I generate them, with a tool called Prism that reads the Lit component definitions and emits native bindings for each target framework. Prism lives in its own repo because it isn’t specific to ARC UI; it’s a compiler pass over custom element metadata.
The reasoning is simple: a wrapper is a mechanical, repeatable, idempotent translation of a contract. Mechanical translation is exactly what should not be done by hand, repeatedly, across six targets, forever.
What a wrapper has to do
A framework binding for a web component isn’t much code, but every line of it is a place where two different component models have to be reconciled, and reconciliation is where drift lives.
Take props. A Lit component exposes reactive properties, some of which reflect to attributes and some of which don’t. Objects and arrays generally can’t round-trip through an HTML attribute, so they have to be set as properties on the element instance rather than serialized as strings. A React wrapper has to know, per prop, whether to hand a value to setAttribute or assign it directly to the DOM node. Get that wrong for one prop on one component and you’ve got a bug that only shows up when someone passes an array where the wrapper expected a string.
Events are the same problem in the other direction. Custom elements communicate out via CustomEvent, which is a DOM-native concept none of these frameworks share. React wants onSomeEvent props wired to addEventListener/removeEventListener with correct cleanup on unmount. Vue wants them as emits mapped to the native event name. Svelte wants on: directives to just work. None of this is hard, individually. It’s tedious to get right per event, per component, and per framework, which is the condition under which humans stop being careful around component 80.
Slots are where the frameworks diverge more sharply. Some frameworks have a native concept close enough to slot projection that the binding can pass through with minor adjustment. Others need the wrapper to detect named slot content in whatever the framework’s children/composition model is and project it into the right place in the light DOM so the browser’s slot algorithm can pick it up. Getting the default slot right while also handling two or three named slots on the same component is exactly the kind of case that gets tested once, ships, and is never revisited when the underlying component adds a fourth slot.
Then there’s SSR. Server-rendered frameworks want to output real markup instead of an empty custom element tag waiting for JavaScript to hydrate it. That means the wrapper has to know what the component’s server-side representation should look like before the custom element registry has done anything: declarative shadow DOM where the target supports it, sensible fallback markup where it doesn’t. This is the single easiest place for a hand-maintained wrapper to quietly become wrong, because it only breaks for users who disable JavaScript or hit the page before hydration, which is exactly the population that never files an issue.
None of these are algorithmically hard problems, and that matters.
Tip
If you’re evaluating a web-component-based library for a React or Vue codebase, ask how the framework bindings are produced. “Generated from the source component” and “written by a contributor two years ago” are very different maintenance guarantees, even if the code looks identical today.
Easy problems can be enumerated
Because none of it is algorithmically hard, all of it is enumerable. Prism reads each Lit component’s reactive property declarations, its declared custom events, and its slot structure, and produces the binding code from that metadata instead of a person’s memory of what the component looked like when the wrapper was last touched. When a component gains a property, the binding gains it automatically. When an event name changes, every framework’s binding changes with it, in the same commit, because they’re outputs of the same generation step. Nobody has to keep six mental models in sync.
That’s the argument for codegen over hand-maintenance, and it has nothing to do with saving typing. Hand-written wrappers fail by omission: someone adds a prop to the Lit component and forgets, or doesn’t know, that five other packages need the same change. Generated wrappers can’t fail by omission in that sense, because there’s no separate act of remembering. The binding is a projection of the component definition, and projections don’t get stale independently of what they’re projecting.
The hard part of supporting six frameworks was keeping six copies of the same decision correct over time. Prism exists so that decision is made once, in the Lit source, and the rest is mechanical.
Content may be edited with the help of AI. All content has been reviewed by a human before being published.
Have a project in mind?
I design and ship custom software — from early concept to production. Tell me what you're building and we'll figure it out together.