Why PolyUI
You've seen this.
Same design, four repos, four token systems, diverging upgrade schedules. Change one button color: that's four PRs.
Status quo · Four diverging forks
shadcn/ui
React
npx shadcn@latest add buttonshadcn-vue
Vue
npx shadcn-vue@latest add buttonshadcn-svelte
Svelte
npx shadcn-svelte@latest add buttonshadcn-solid
Solid
npx shadcn-solid@latest add buttonDifferent token formats. Different upgrade cadences. Change one, fix four.
PolyUI · Single source of truth
React
Base UI
Vue
Reka UI
Svelte
Bits UI
Solid
Kobalte
@polyui/tokens + @polyui/core
--primary · --radius · variants · props contract
Change one token. All four frameworks update.
Architecture
Three layers, cleanly separated.
Understand these three layers and you understand PolyUI. Layers 1 and 2 are fully shared. Layer 3 adapts per framework, but it's thin.
@polyui/tokens · Token layer
Pure CSS variables. Your token.css overrides this layer. CSS cascade wins; no rebuild needed.
:root {
--primary: oklch(0.72 0.18 55);
--radius: 8px;
}@polyui/core · Recipe layer
Pure JS functions. All four framework packages import it. Which variant maps to which classes is defined once.
export const buttonVariants = tv({
base: "inline-flex items-center…",
variants: { variant: { default: "bg-primary…" } },
})Framework adapter layer
Each framework uses its most mature headless primitives for interaction and accessibility. It only renders tokens and variants into the DOM.
import { Button } from "@polyui/react"
// @polyui/vue · @polyui/svelte · @polyui/solidLayers 1 and 2: 100% shared. Layer 3: adapts, but thin. That's the whole story.
Tier 1 · Runtime tokens
One file. Change everything.
Edit the token.css on the left. Components on the right respond instantly. No build step. This is exactly what you do in your own project.
Try changing the hue number in --primary.
Props contract
Different syntax. Same props.
A more complex Dialog component proves the point: even for stateful interactive components, PolyUI prop names stay aligned across all four frameworks.
// @polyui/reactimport { Dialog, DialogContent, DialogTitle, Button } from "@polyui/react"function ConfirmDelete({ open, onClose }) { return ( <Dialog open={open} onOpenChange={onClose}> <DialogContent> <DialogTitle>Delete item</DialogTitle> <p>This action cannot be undone.</p> // ← variant, DialogTitle, open: same in all 4 <Button variant="destructive" onClick={onClose}> Confirm </Button> </DialogContent> </Dialog> )}<!-- @polyui/vue --><script setup>import { Dialog, DialogContent, DialogTitle, Button } from "@polyui/vue"const props = defineProps(['open'])const emit = defineEmits(['update:open'])</script><template> <Dialog :open="open" @update:open="emit('update:open', $event)"> <DialogContent> <DialogTitle>Delete item</DialogTitle> <p>This action cannot be undone.</p> <!-- ← variant, DialogTitle, open: same in all 4 --> <Button variant="destructive" @click="emit('update:open', false)"> Confirm </Button> </DialogContent> </Dialog></template><!-- @polyui/svelte --><script> import { Dialog, DialogContent, DialogTitle, Button } from "@polyui/svelte" export let open = false</script><Dialog bind:open> <DialogContent> <DialogTitle>Delete item</DialogTitle> <p>This action cannot be undone.</p> <!-- ← variant, DialogTitle, open: same in all 4 --> <Button variant="destructive" onclick={() => open = false}> Confirm </Button> </DialogContent></Dialog>// @polyui/solidimport { Dialog, DialogContent, DialogTitle, Button } from "@polyui/solid"function ConfirmDelete(props) { return ( <Dialog open={props.open()} onOpenChange={props.onClose}> <DialogContent> <DialogTitle>Delete item</DialogTitle> <p>This action cannot be undone.</p> // ← variant, DialogTitle, open: same in all 4 <Button variant="destructive" onClick={props.onClose}> Confirm </Button> </DialogContent> </Dialog> )}The rendered output of the above four snippets:
Delete item
╳This action cannot be undone.
Different syntax. Same component.
Components
What's included
Covers the full shadcn component set. Advanced components prefer engines with native multi-framework adapters.
Primitives
Composite components
Advanced
Engine-agnosticData Table
Official 4-framework adapters
Virtual List
Official 4-framework adapters
Carousel
Framework-agnostic core
Rich Text
Official multi-framework bindings
Combobox
Date Picker
Render per-framework
Form
Cross-framework validation reuse
Visual parity components
Token-consistent, engine variesCharts
Token/visual consistent, engines vary
Token and visual appearance stay consistent. Engine is per-framework best-fit. This is a known tradeoff; see Tradeoffs.
Tradeoffs
What we don't pretend to do
We've all seen it: finding the caveat in a footnote costs far more than knowing it upfront.
We're not Mitosis.
PolyUI is not a compile-once-run-four-frameworks solution. Logic is implemented per framework (Base UI / Reka / Bits / Kobalte). We just make that layer as thin as possible. Predictability over magic.
Maintenance cost scales N×.
Non-engine-driven components may require interaction logic written four times in the worst case. That's why advanced components strongly prefer engines like TanStack, Embla, or Tiptap that ship native multi-framework adapters.
Charts: visual parity, not engine parity.
No single chart engine covers all four frameworks. Token and visual appearance stay consistent; the rendering engine is per-framework best-fit. Known tradeoff, not a bug.
Svelte / Solid ship after React.
The React headless primitive ecosystem is the most mature. Some components will ship React / Vue first, with Svelte / Solid following. The M4 milestone in the roadmap closes this gap.
We wrote this section because we've seen the footnote-caveat pattern too many times.
Quick start
Add to your project
Choose your framework. Paste the command. Start writing components.
$pnpm dlx polyui init --framework next$pnpm dlx polyui add button card dialog