Skip to main content
PolyUI

React · Vue · Svelte · Solid

Same Component.
Four Frameworks.

One token file overrides everything. At runtime. No rebuild. Props align across React, Vue, Svelte, and Solid.

React / Vue / Svelte / SolidTailwind v4 + OKLCH tokensMIT License
LoginCard.tsx
// @polyui/reactimport { Card, Input, Button, Label } from "@polyui/react"
export function LoginCard() {
  return (
    <Card>
      <Label>Email</Label>
      <Input type="email" />
      <Label>Password</Label>
      <Input type="password" />
      <Button variant="default">Sign in</Button>
    </Card>
  )
}
LIVE
:root {
  --primary: oklch(0.72 0.18 55);
  --radius:  8px;
}

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 button

shadcn-vue

Vue

npx shadcn-vue@latest add button

shadcn-svelte

Svelte

npx shadcn-svelte@latest add button

shadcn-solid

Solid

npx shadcn-solid@latest add button

Different 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.

Layer 1 · Pure CSS

@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;
}
Layer 2 · Framework-agnostic

@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…" } },
})
Layer 3

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/solid

Layers 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.

token.css

Try changing the hue number in --primary.

Live preview
LIVE
DefaultSuccessInfo
Card title

All rounded corners and colors respond to token.css changes in real time.

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.

React
// @polyui/react
import { 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>
)
}
Vue
<!-- @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>
Svelte
<!-- @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>
Solid
// @polyui/solid
import { 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.

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