網站導覽

撰寫樣式

撰寫套件

發布共用 CSS 原始碼、在各應用程式覆寫公開 token,並透過正式環境使用端驗證打包後的套件。

本頁目錄

#概覽

發布 CSS 原始碼,以共用穩定的樣式詞彙。各使用端應用程式將來源匯入自己的 Master CSS 進入點,並編譯所需 token、utility 與原生元件。詞彙在單一應用程式正常運作後,再從這裡開始;重用樣式說明了前一步。

這種套件形式應發布原始定義,不是應用程式產生的樣式表或 manifest。應用程式便能自行選擇類別用法、token 覆寫與傳遞模式。若套件也提供執行階段整合,則有另一份產物契約。

共用定義,各應用程式自行編譯

兩個應用程式匯入相同 CSS 原始碼。原生元件預設包含;各應用程式只產生自己標記所請求的 utility。

文字說明
  1. 共用原始碼. 套件定義原生 .btn 元件,以及隨需產生的 content-auto utility。
  2. 應用程式 A. 標記使用 btn,原生元件會包含在輸出,但未使用的 content-auto utility 不會輸出。
  3. 應用程式 B. 標記使用 btn 與 content-auto。輸出包含原生元件與請求的 utility。

#建立套件

本範例使用 @acme/ui-theme;發布前請將 scope 替換成你擁有的名稱。使用端以套件名稱匯入,匯出的 CSS 檔案為 master.css。

  • my-master-theme
    • package.json
    • master.css
    • README.md

將套件根匯出指向 master.css:

package.json
{  "name": "@acme/ui-theme",  "version": "0.1.0",  "files": ["master.css"],  "style": "./master.css",  "exports": {    ".": {      "style": "./master.css",      "default": "./master.css"    }  },  "sideEffects": ["./master.css"]}

這種套件形式不需要打包工具、TypeScript 建置、manifest 產生或編譯後的 CSS 輸出。套件發布的是 CSS 原始碼,支援 Master CSS 的專案可將它納入自己的樣式表圖編譯。


#撰寫 master.css

將使用端專案應可取得的共用詞彙,放進 master.css。

master.css
@theme {  --color-brand: #4f46e5;  --color-text-action: var(--color-text-strong);  --color-focus-action: var(--color-text-strong);  --spacing-action-x: 1rem;  --radius-action: 0.375rem;}@custom-media --theme-motion (prefers-reduced-motion: no-preference);@utility content-auto {  content-visibility: auto;  contain-intrinsic-size: auto 32rem;}@layer components {  .btn {    display: inline-flex;    align-items: center;    justify-content: center;    gap: var(--spacing-xs);    min-height: 2.375rem;    max-width: 100%;    padding: .5rem var(--spacing-action-x);    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-action);    font-size: var(--font-size-sm);    font-weight: 400;    line-height: 1.2;    background-color: var(--color-surface-raised);    color: var(--color-text-action);    box-shadow: var(--shadow-sm);    &:hover {      box-shadow: var(--shadow-md);    }    &:active {      background-color: var(--color-surface-inset);      box-shadow: none;    }    &:focus-visible {      outline: 2px solid var(--color-focus-action);      outline-offset: 3px;    }  }}

此範例提供顏色標記、原生 .btn 元件、隨需產生的 content-auto utility,以及具名 theme-motion 條件。元件擁有共用狀態;應用程式選擇是否需要轉場。原生元件 CSS 預設會包含,utility 則只在使用時產生規則。

選擇使用端需要的最小公開詞彙:

  • 使用 @theme 共用顏色、間距、圓角、文字排版、陰影、動態效果與容器 token 等值。
  • 媒體名稱使用 @custom-media;其他可重用包裝則使用帶 @contents 的 @mixin。
  • 隨需產生的類別使用 @utility;原生 CSS 內部重用使用 @mixin。
  • 只有套件刻意擁有 btn、field、card、toolbar 這類語意 UI 詞彙時,才使用原生 @layer components。

token 命名請參閱主題 token,指令語法請參閱 CSS 指令,選擇抽象方式請參閱全域樣式。


#使用套件

安裝套件後,在預設 Master CSS 進入點之後匯入:

app.css
@import '@master/css';@import '@acme/ui-theme';

匯入的原始碼會成為專案 CSS 進入點圖的一部分。專案標記可將共用詞彙作為 utility 與原生 CSS 類別使用:

共用套件詞彙

<article class="p-lg b-solid b(1) b-line-divider r-lg content-auto">  <p class="flex items-center gap-xs m(0) text-xs fg-text-muted"><span aria-hidden="true" class="h(8) w(8) pill bg-brand"></span>Package brand</p>  <h2 class="mb-xs mt-sm text-lg font-regular">Shared vocabulary</h2>  <p class="mt(0) mb-md text-sm fg-text-muted">The app supplies the markup; the package supplies the shared action.</p>  <button type="button" class="btn (transition-property:box-shadow,background-color)@theme-motion (transition-duration:var(--duration-fast))@theme-motion (transition-timing-function:var(--easing-smooth))@theme-motion">Preview action</button></article>
產生的 CSS
@layer components {  .btn {    justify-content: center;    align-items: center;    gap: var(--spacing-xs);    max-width: 100%;    min-height: 2.375rem;    padding: .5rem var(--spacing-action-x);    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-action);    font-size: var(--font-size-sm);    background-color: var(--color-surface-raised);    color: var(--color-text-action);    box-shadow: var(--shadow-sm);    font-weight: 400;    line-height: 1.2;    display: inline-flex;    &:hover {      box-shadow: var(--shadow-md);    }    &:active {      background-color: var(--color-surface-inset);      box-shadow: none;    }    &:focus-visible {      outline: 2px solid var(--color-focus-action);      outline-offset: 3px;    }  }}@layer theme {  :root,  :host {    --color-zinc-5: oklch(96.5% .0011 265);    --color-zinc-40: oklch(68.6% .0028 265);    --color-zinc-60: oklch(48.6% .003 265);    --color-zinc-90: oklch(24.6% .0022 265);    --color-zinc-100: oklch(15% .0016 265)  }  :root,  :host {    --color-surface-inset: light-dark(var(--color-zinc-5), var(--color-zinc-100));    --color-surface-raised: light-dark(var(--color-white), var(--color-zinc-90));    --color-line-divider: light-dark(oklch(0% 0 none / .12), oklch(100% 0 none / .12));    --color-text-strong: light-dark(var(--color-zinc-100), var(--color-white));    --color-text-muted: light-dark(var(--color-zinc-60), var(--color-zinc-40))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-weight-regular: 400;    --font-size-xs: .75rem;    --font-size-sm: .875rem;    --font-size-lg: 1.125rem;    --text-xs: var(--font-size-xs);    --text-xs--line-height: 1.5;    --text-xs--letter-spacing: 0;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --text-lg: var(--font-size-lg);    --text-lg--line-height: 1.5;    --text-lg--letter-spacing: 0;    --radius-lg: .5rem;    --spacing-xs: .5rem;    --spacing-sm: .75rem;    --spacing-md: 1rem;    --spacing-lg: 1.5rem;    --duration-fast: .15s;    --easing-smooth: cubic-bezier(.4, 0, .2, 1)  }  :root,  :host {    --shadow-sm: 0 0 0 1px light-dark(oklch(0% 0 none / .04), oklch(100% 0 none / .05)), 0 1px 1px -.5px light-dark(oklch(0% 0 none / .045), oklch(0% 0 none / .3)), 0 2px 2px -1px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .26)), 0 4px 4px -2px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .24));    --shadow-md: 0 0 0 1px light-dark(oklch(0% 0 none / .05), oklch(100% 0 none / .06)), 0 1px 1px -.5px light-dark(oklch(0% 0 none / .045), oklch(0% 0 none / .3)), 0 2px 2px -1px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .26)), 0 4px 4px -2px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .24)), 0 8px 8px -4px light-dark(oklch(0% 0 none / .045), oklch(0% 0 none / .26))  }  :root,  :host {    --color-brand: #4f46e5;    --color-text-action: var(--color-text-strong);    --color-focus-action: var(--color-text-strong);    --spacing-action-x: 1rem;    --radius-action: .375rem  }}@layer utilities {  .text-lg {    font-size: var(--text-lg);    line-height: var(--text-lg--line-height, normal);    letter-spacing: var(--text-lg--letter-spacing, normal)  }  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .text-xs {    font-size: var(--text-xs);    line-height: var(--text-xs--line-height, normal);    letter-spacing: var(--text-xs--letter-spacing, normal)  }  .content-auto {    content-visibility: auto;    contain-intrinsic-size: auto 32rem  }  .items-center {    align-items: center  }  .bg-brand {    background-color: var(--color-brand)  }  .b-line-divider {    border-color: var(--color-line-divider)  }  .r-lg {    border-radius: var(--radius-lg)  }  .pill {    border-radius: calc(infinity * 1px)  }  .b-solid {    border-style: solid  }  .b\(1\) {    border-width: calc(1 * 1px)  }  .fg-text-muted {    color: var(--color-text-muted)  }  .flex {    display: flex  }  .font-regular {    font-weight: var(--font-weight-regular)  }  .gap-xs {    gap: var(--spacing-xs)  }  .h\(8\) {    height: calc(8 * 1px)  }  .m\(0\) {    margin: calc(0 * 1px)  }  .mb-md {    margin-bottom: var(--spacing-md)  }  .mb-xs {    margin-bottom: var(--spacing-xs)  }  .mt-sm {    margin-top: var(--spacing-sm)  }  .mt\(0\) {    margin-top: calc(0 * 1px)  }  .p-lg {    padding: var(--spacing-lg)  }  .w\(8\) {    width: calc(8 * 1px)  }  @media (prefers-reduced-motion: no-preference) {    .\(transition-duration\:var\(--duration-fast\)\)\@theme-motion {      transition-duration: var(--duration-fast)    }  }  @media (prefers-reduced-motion: no-preference) {    .\(transition-property\:box-shadow\,background-color\)\@theme-motion {      transition-property: box-shadow, background-color    }  }  @media (prefers-reduced-motion: no-preference) {    .\(transition-timing-function\:var\(--easing-smooth\)\)\@theme-motion {      transition-timing-function: var(--easing-smooth)    }  }}
將滑鼠移到按鈕上、按下或聚焦,檢查套件的共用狀態;它不會執行儲存動作。應用程式使用套件的 theme-motion 條件,尊重減少動態效果偏好。

專案進入點已匯入的套件,本就可供區域樣式表使用。若只要將套件加入單一樣式表的編譯脈絡,請使用 @reference:

components/Button.module.css
@reference '@acme/ui-theme';.button {  padding-inline: var(--spacing-action-x);  border-radius: var(--radius-action);  background-color: var(--color-brand);}

區域 .button 使用套件的間距、圓角與品牌 token。編譯此樣式表時,@reference 提供定義,輸出也包含必要的主題資源。它不會複製套件原生 .btn 規則或產生的 utility 類別。需要這些規則時,請另行載入套件 CSS。區域樣式表模式請參閱路由層級樣式。


#在使用端覆寫公開值

將應用程式專用選擇放在套件匯入之後。例如,此應用程式加寬共用操作按鈕,無須複製元件 CSS:

app.css
@import '@master/css';@import '@acme/ui-theme';@theme {  --spacing-action-x: 1.5rem;}

.btn 仍讀取 var(--spacing-action-x)。應用程式將水平內距從 1rem 改為 1.5rem;其他應用程式保留各自的值。請記錄使用端可依賴的 token 名稱、元件選擇器與 utility 名稱。即使套件不含 JavaScript,重新命名任何公開名稱,仍會影響使用端標記或樣式表。

覆寫值,保留元件

使用端覆寫位於套件匯入之後。內縮輔助線以 16px 根字型比較 1rem 與 1.5rem;.btn 規則仍然共用。

文字說明

套件與應用程式共用同一份 .btn 定義。應用程式修改 --spacing-action-x,加大水平內距,同時保留按鈕本身的角色。

#應用程式政策留在應用程式內

可重用的樣式撰寫套件,不應決定某個應用程式如何掃描、篩選或發布原始檔。

下列內容保留在使用端應用程式:

  • 應用程式專用的 @source 與 @source not 規則。
  • 應用程式專用的 @safelist 與 @blocklist 政策。
  • 路由、頁面或元件的一次性類別。
  • 產生的 manifest JSON 與編譯後的 CSS 輸出。

以套件提供共用詞彙;以應用程式 CSS 進入點管理區域原始碼邊界、傳遞選擇與應用程式專屬政策。


#使用範例驗證

從套件目錄打包原始碼。npm pack 會建立本地封存檔,不會發布到套件註冊表:

npm pack

依範例名稱與版本,封存檔為 acme-ui-theme-0.1.0.tgz。確認包含 master.css 與預期的 package.json 匯出。files 清單排除無關原始碼與建置產物;npm 也會包含標準中繼資料檔案。

將封存檔安裝到已整合 Master CSS 的小型使用端應用程式:

npm install ../my-master-theme/acme-ui-theme-0.1.0.tgznpm run build

將上方標記放進會掃描的原始檔。在正式環境輸出檢查原生 .btn 規則、請求的 content-auto utility 與必要主題資源。再開啟應用程式,檢查按鈕的計算後內距,並加入區域覆寫後重複檢查。除了靜止狀態,也要驗證懸停、鍵盤焦點與減少動態效果。

使用框架整合的建置來驗證套件解析。目前的獨立 CLI 檢查可能載入應用程式進入點,卻將第三方裸套件匯入留在樣式表圖之外。請檢查列出的樣式表相依性:若未包含套件的 master.css,該檢查就尚未驗證這份詞彙。正式環境使用端也能驗證本地僅原始碼預覽無法檢查的檔案與匯出路徑。