同一份設計,跑在 Svelte / React / Vue / Next
為什麼是純 CSS 變數而不是 Tailwind、四個框架的模板對照表,以及移植時的逐項檢查。
1. 為什麼不用 Tailwind
Tailwind 很好用,但這套系統要同時活在四個框架的專案裡,而 Tailwind 帶來三個移植障礙:
- 設定檔綁定 —
tailwind.config.js要進你的專案,跟你既有的設定合併 - 建置步驟綁定 — 換風格要重跑 build,不能只換一個檔案
- 看不見的設計決策 —
p-4 gap-6 rounded-2xl讀不出設計意圖,而且無法在執行期切換
CSS 自訂屬性是四個框架原生共同支援的唯一語言。它在執行期就能換、不需要建置、複製貼上就會動。
代價是要自己寫 CSS。對一個設計系統來說,這是應該的 —— 設計系統的產出本來就該是 CSS。
2. 移植的三個部分
tokens.css 原封不動 ← 四個框架都一樣
component.css 原封不動 ← 只要 class 名稱不變
模板 需要轉換 ← 唯一要動的東西
所以移植的工作量只有第三項,而且都是機械式的替換。
3. 模板對照表
| Svelte | React / Next | Vue 3 |
|---|---|---|
class="x" |
className="x" |
class="x" |
for="id" |
htmlFor="id" |
for="id" |
onclick={fn} |
onClick={fn} |
@click="fn" |
oninput={fn} |
onChange={fn} |
@input="fn" |
bind:value={x} |
value={x} onChange={e => setX(e.target.value)} |
v-model="x" |
bind:checked={x} |
checked={x} onChange={...} |
v-model="x" |
{#if c}…{/if} |
{c && …} |
v-if="c" |
{#if c}…{:else}…{/if} |
{c ? … : …} |
v-if / v-else |
{#each xs as x (x.id)} |
{xs.map(x => <… key={x.id}>)} |
v-for="x in xs" :key="x.id" |
{value} |
{value} |
{{ value }} |
{@html s} |
dangerouslySetInnerHTML={{__html: s}} |
v-html="s" |
let x = $state(0) |
const [x, setX] = useState(0) |
const x = ref(0) |
$derived(a + b) |
a + b(直接算) |
computed(() => a + b) |
$effect(() => …) |
useEffect(() => …, [deps]) |
watchEffect(() => …) |
<style>(自動 scoped) |
匯入 component.css | <style scoped> |
4. 各框架的注意事項
React
class→className,for→htmlFor。忘記的話樣式整個不見,而且 console 只給一行警告- 每個 map 出來的元素要 key,而且要用穩定的 id,不要用 index(見 forms-focus-input)
- 自閉合標籤:
<br />、<img />、<input /> - inline style 是物件:
style={{ width: '50%' }} - CSS 變數在 inline style 要用字串 key:
style={{ ['--x' as string]: value }}
Next.js(App Router)
- 預設是 Server Component。有 useState、事件處理、瀏覽器 API 的檔案,第一行加
"use client" - CSS 在
app/layout.tsximport,順序是 contract → components → tokens - 圖片可以用
next/image,但要記得它會自動加 lazy,首屏圖片要priority - 字型用
next/font可以避免 CLS
Vue 3
<style scoped>的 scope 機制跟 Svelte 類似,但深層選擇器要用:deep()v-html的內容一樣要淨化v-for的 key 要放在v-for所在的那個元素上- 事件修飾符(
@click.prevent)可以取代手寫preventDefault
純 HTML
- 把
{#each}/{#if}展開成靜態標記,class 名稱一個字都不要改 - 需要互動的地方用最小量的 vanilla JS
- 原生
<dialog>與<details>幾乎涵蓋你需要的互動
5. 元件的 CSS 怎麼搬
Svelte 的 <style> 是編譯期 scoped,抽出來會失去隔離。本站的下載包已經幫你處理好:每個模組附一份 component.css,所有選擇器都加上了包裹 class。
/* 原本(Svelte scoped) */
.hero { … }
.wordmark { … }
/* 匯出後 */
.px-landing-hero-illustration .hero { … }
.px-landing-hero-illustration .wordmark { … }
用法:把區塊包在 <div class="px-landing-hero-illustration"> 裡。
@keyframes 不會被加前綴(加了會壞),所以動畫名稱要夠獨特。
6. 移植檢查清單
搬完一個區塊,逐項確認:
- 所有
class名稱跟原本完全一致(改了名字,CSS 就對不上) -
data-*屬性都保留了(變體是靠它們) - 迴圈都有穩定的 key
- 事件名稱轉換正確
- 表單的
label/for關係還在 -
aria-*屬性都保留了 - CSS 三個檔案的 import 順序正確
- 換一個
tokens.css版面不會壞 - 360 / 768 / 1440 三個寬度都試過
最後兩項是最容易漏、也最能證明搬對了的。
7. 讓 AI 幫你搬
本站每個模組頁都可以選目標框架,產生的 prompt 會自動帶上對應的轉換規則。做法:
- 在模組頁選框架
- 「下載檔案包」拿到 Svelte 原始碼 + 去 scope 化的 CSS
- 「複製完整 Prompt」
- 貼給 Claude Code,把資料夾一起給它
prompt 裡已經包含上面那張對照表的相關條目,以及移植檢查清單。