Most “multi-framework” component libraries are really one library plus two ports. The flagship framework gets the new features, the fixes, and the attention; the others get a best-effort translation that drifts a release or two behind. Six months in, the Vue binding renders a slightly different DOM, the Angular one is missing a prop, and the docs quietly stop mentioning parity at all.
Sisyphos UI ships 33 components for React, Vue 3, and Angular 18 from a single monorepo, and parity is not a promise — it is an architectural constraint. This post walks through how that constraint is enforced: a shared core package, a byte-identical stylesheet contract, and an 841-test suite that asserts the same behavior three times over.
The layered monorepo
The repo is a pnpm workspace with four published packages. Three of them are framework bindings; one of them is the reason the bindings cannot drift:
@sisyphos-ui/core— design tokens (emitted as CSS custom properties), theapplyTheme()runtime theming engine, and a set of framework-agnostic internal utilities:cxfor class composition,mergeRefs, floating-elementplacementmath, and hooks likeuse-escape-keyanduse-outside-click.@sisyphos-ui/react— React bindings, one folder per component, re-exported from a tree-shakable ESM barrel.@sisyphos-ui/vue— Vue 3 single-file components with the same folder layout and the same barrel.@sisyphos-ui/angular— Angular 18 standalone components. No NgModule wrappers: you import the component class directly into yourimportsarray.
Anything that can be written once lives in core. Anything that must be idiomatic to the framework — event wiring, reactivity, ref forwarding — lives in the binding. The line between the two is the interesting part.
The CSS contract: identical class names, identical stylesheets
The strongest parity guarantee in the system is the styling contract. Every component has a root class (sisyphos-switch, sisyphos-card, sisyphos-tabs…) plus a small vocabulary of state and modifier classes: checked, unchecked, disabled, semantic color names like primary or error, and size tokens like md. The React package pins these in a constants module so they can't be fat-fingered:
export const CN = {
switch: "sisyphos-switch",
toggle: "sisyphos-switch-toggle",
size: (v: Scale) => v,
} as const;
export const DEFAULTS = {
color: "primary",
size: "md" as Scale,
} as const;The Vue binding builds the exact same class list with a computed, and the Angular component does it in a host-binding. Because the class names match, the SCSS can be shared verbatim: the Switch.scss in the React package and the one in the Vue package are byte-identical files, and both compile against the same token variables from core. A visual fix lands in one stylesheet and ships to every framework, because there is effectively one stylesheet.
This also means the rendered DOM is inspectable in the same way everywhere. If your end-to-end tests select .sisyphos-switch.checked, they keep working when a team migrates a screen from Vue to React.
Idiomatic bindings, not identical ones
Parity does not mean pretending the frameworks are the same. The Switch is always a controlled component, but each binding expresses that in its framework's native dialect:
import { Switch } from "@sisyphos-ui/react";
<Switch
checked={enabled}
onChange={setEnabled}
aria-label="Enable notifications"
/><script setup lang="ts">
import { ref } from "vue";
import { Switch } from "@sisyphos-ui/vue";
const enabled = ref(false);
</script>
<template>
<Switch v-model:checked="enabled" aria-label="Enable notifications" />
</template>import { Component } from "@angular/core";
import { Switch } from "@sisyphos-ui/angular";
@Component({
standalone: true,
imports: [Switch],
template: `
<sui-switch [(checked)]="enabled" aria-label="Enable notifications" />
`,
})
export class SettingsComponent {
enabled = false;
}React gets checked + onChange. Vue gets v-model:checked plus a sibling change event. Angular gets a signal-based model() for two-way [(checked)] binding — and because the component also implements ControlValueAccessor, it plugs straight into ngModel and Reactive Forms. Under the surface, all three render a native <button> with role="switch", aria-checked, and identical Space/Enter key handling. The API shape bends to the framework; the accessible behavior does not.
The 841-test suite is the real enforcement mechanism
Shared code and shared CSS prevent a lot of drift, but behavior parity is enforced where it should be: in tests. Every component has a spec file in every framework package — 33 test files under packages/react, 33 under packages/vue, 33 under packages/angular — adding up to 841 tests across the monorepo. The suites deliberately mirror each other. Here is the Switch, three times:
// packages/react/src/switch/Switch.test.tsx
it("renders with role=switch and the correct aria-checked", ...)
it("toggles via mouse click", ...)
it("toggles via Space and Enter keys", ...)
it("does not fire onChange when disabled", ...)
// packages/vue/src/switch/Switch.test.ts
it("exposes role=switch and aria-checked", ...)
it("emits update:checked on click", ...)
it("Space and Enter activate the switch", ...)
it("disabled blocks toggle", ...)
// packages/angular/src/switch/switch.component.spec.ts
it("renders a role=switch button", ...)
it("aria-checked reflects checked state", ...)
it("Enter and Space toggle when focused", ...)
it("disabled prevents toggle on click", ...)When a behavior changes — say, Escape handling in the Dialog, or how a disabled Radio responds to arrow keys — the change is not done until all three suites assert it. A pull request that fixes React and skips Vue fails review on structure alone, because the missing mirrored test is visible in the diff.
One test runner, two configs
The whole thing runs on Vitest with jsdom and Testing Library. React and Vue share a single root config — the @vitejs/plugin-react and @vitejs/plugin-vue plugins coexist cleanly because each only processes its own file types (JSX/TSX versus .vue SFCs). Angular is the exception: it compiles through @analogjs's Angular plugin, which would clash with the other two on shared TypeScript files, so it gets its own Vitest config inside its package:
pnpm test # vitest run && pnpm --filter @sisyphos-ui/angular test
pnpm test:react-vue # react + vue suites in one process
pnpm test:angular # the angular suite on its own configThis split is invisible in CI — pnpm test runs everything — but it is worth knowing if you contribute, because it explains why an Angular-only failure sometimes reproduces only under test:angular.
Where shared logic is headed: state machines in core
The newest layer of the architecture pushes parity one level deeper. Core has a machinesmodule — the Checkbox is the first resident — that extracts component state transitions into pure, framework-agnostic functions. The transition rule that toggling an indeterminate checkbox promotes it to checked (the standard “select all” behavior) is written exactly once, as nextCheckboxStateAfterToggle(), with its own unit tests in core:
export function nextCheckboxStateAfterToggle(state: CheckboxState): CheckboxState {
if (state.disabled) return state;
return {
...state,
checked: state.indeterminate ? true : !state.checked,
indeterminate: false,
};
}The module ships two surfaces on purpose: pure helpers for controlled bindings where the host framework owns state (React's useState, Vue's v-model), and a stateful createCheckbox()controller with a pub/sub API for imperative use — which adapts cleanly to Vue's customRef or an Angular Observable. As more components migrate to this pattern, the bindings shrink toward what they should be: thin adapters between a framework's reactivity model and logic that is tested once.
The same single-source discipline extends past the code. The docs site, the per-component markdown exports, and the MCP server all read one component registry that carries a snippet per framework — so the React, Vue, and Angular tabs on a component page can never document three different libraries.
What lockstep actually buys you
The obvious beneficiary is the mixed-stack organization: a React product, a Vue admin panel, an Angular legacy app, one design language across all three, one theming call (applyTheme() lives in core and is framework-agnostic by construction). But the subtler win is migration insurance. Frameworks have a longer half-life than they used to, but products still outlive stacks. If the component layer holds its class names, its tokens, and its accessible behavior constant across frameworks, a rewrite becomes a mechanical translation of templates rather than a redesign.
None of this is free for maintainers — every component is written three times, and every behavioral test is written three times. That is exactly the point. The cost of parity is paid once, in the library, instead of repeatedly, in every consuming team's bug tracker.