为什么会有 PolyUI
你见过这个吗?
同一套设计,四个仓库,四套令牌,各自升级,互不兼容。改一个按钮颜色,就是四个 PR。
现状 · 四套各自为政
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 button令牌写法各异。升级节奏不同。改一处,四处返工。
PolyUI · 单一真相来源
React
Base UI
Vue
Reka UI
Svelte
Bits UI
Solid
Kobalte
@polyui/tokens + @polyui/core
--primary · --radius · variants · props 契约
一次改令牌。四个框架同步更新。
架构
三层分离。
理解这三层,就理解了 PolyUI。第 1、2 层完全共享;第 3 层适配框架,但足够薄。
@polyui/tokens · 令牌层
纯 CSS 变量。你的 token.css 覆盖这一层。CSS 层叠胜出,无需重建。
:root {
--primary: oklch(0.72 0.18 55);
--radius: 8px;
}@polyui/core · 配方层
纯 JS 函数。四个框架包都 import 它。"哪个 variant 对应哪些 class"只写一次。
export const buttonVariants = tv({
base: "inline-flex items-center…",
variants: { variant: { default: "bg-primary…" } },
})框架适配层
各框架用最成熟的 headless 原语实现交互与可访问性。只负责"把令牌和 variants 渲染成 DOM"。
import { Button } from "@polyui/react"
// @polyui/vue · @polyui/svelte · @polyui/solid第 1、2 层 100% 共享。第 3 层适配,但足够薄。这就是全部秘密。
Tier 1 · 运行时令牌
一个文件。全部改变。
修改左侧 token.css,右侧组件即时响应。不需要构建。这就是你在项目里做的事情。
试着改改 --primary 的色相数字。
Props 契约
语法不同。Props 一致。
用比登录卡更复杂的 Dialog 演示:哪怕是有状态的交互组件,PolyUI 的 props 命名在四个框架中始终对齐。
// @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> )}以上四段代码的渲染结果:
删除项目
╳此操作无法撤销。
语法各异。组件相同。
组件
包含什么
涵盖 shadcn 全组件集,高级组件优先选用自带多框架适配器的引擎。
基础原语
组合组件
高级组件
引擎框架无关Data Table
官方 4 框架适配
Virtual List
官方 4 框架适配
Carousel
框架无关核心
Rich Text
官方多框架绑定
Combobox
Date Picker
渲染分框架
Form
校验跨框架复用
视觉对齐组件
令牌一致,引擎各异Charts
令牌/视觉一致,引擎各异
令牌和视觉外观保持一致。引擎按各框架最优选。这是已知权衡,见 诚实边界.
诚实边界
我们不假装做到的事
因为我们都见过:在脚注里发现限制,比从一开始就知道,代价要大得多。
我们不是 Mitosis。
PolyUI 不是"一份源码编译到四个框架"的方案。逻辑按框架分别实现(Base UI / Reka / Bits / Kobalte)。我们只是把这层压到最薄。可控性优先于魔法。
维护成本是 N 倍。
非引擎驱动的组件,最坏要写四遍交互逻辑。所以高级组件强烈优先选 TanStack / Embla / Tiptap 这类自带多框架适配器的引擎。
Charts 是视觉对齐,不是引擎统一。
图表生态没有一个可以覆盖四个框架的统一引擎。令牌和视觉外观保持一致,但渲染引擎按框架最优选。这是已知权衡,不是 bug。
Svelte / Solid 比 React 晚。
React headless 原语生态最成熟。部分组件会先发布 React / Vue,Svelte / Solid 版本稍后跟进。Roadmap 里的 M4 里程碑会补齐。
我们写这个区块,是因为我们自己也看过太多次"脚注里的真相"。
快速开始
加入你的项目
选择你的框架。粘贴命令。开始写组件。