網站導覽

撰寫樣式

重用樣式

依哪些內容應一起改變,選擇 token、元件類別、utility 與 mixin,同時保留容易表達的區域變化。

本頁目錄

兩個畫面可以共用儲存按鈕,又讓其中一個實例保有較大間距。將按鈕的共用外觀放進元件類別,區域調整則留在標記中。更小尺度也適用相同方法:多個使用端應一起改變時,為值命名;多條規則需要同一段 CSS 時,就共用那段 CSS。

請先完成安裝與語法教學。下方每個範例都提供自己的 CSS 與標記。將 CSS 加在應用程式樣式表既有的預設集匯入之後,並將標記放進整合會掃描的原始檔:

app.css
@import '@master/css';

#將元件共用外觀放在一起

操作按鈕這類產品角色,無論在哪裡使用,都需要可辨識的外觀。請在 @layer components 中以原生類別定義該角色,包含每個實例都應有的狀態。

同一種按鈕,區域間距調整

@theme {  --spacing-action-x: 1rem;  --color-action: var(--color-surface-raised);}@layer components {  .action {    display: inline-flex;    align-items: center;    justify-content: center;    min-height: 2.375rem;    padding: .5rem var(--spacing-action-x);    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-md);    background-color: var(--color-action);    color: var(--color-text-strong);    box-shadow: var(--shadow-sm);    font: inherit;    font-size: var(--font-size-sm);    font-weight: 400;    line-height: 1.2;    &:is(:hover, :focus-visible) {      box-shadow: var(--shadow-md);    }    &:active {      background-color: var(--color-surface-inset);      box-shadow: none;    }    &:focus-visible {      outline: 2px solid var(--color-text-strong);      outline-offset: 3px;    }  }}
產生的 CSS
@layer components {  .action {    min-height: 2.375rem;    padding: .5rem var(--spacing-action-x);    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-md);    background-color: var(--color-action);    color: var(--color-text-strong);    box-shadow: var(--shadow-sm);    font: inherit;    font-size: var(--font-size-sm);    justify-content: center;    align-items: center;    font-weight: 400;    line-height: 1.2;    display: inline-flex;    &:is(:hover, :focus-visible) {      box-shadow: var(--shadow-md);    }    &:active {      background-color: var(--color-surface-inset);      box-shadow: none;    }    &:focus-visible {      outline: 2px solid var(--color-text-strong);      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-size-xs: .75rem;    --font-size-sm: .875rem;    --text-xs: var(--font-size-xs);    --text-xs--line-height: 1.5;    --text-xs--letter-spacing: 0;    --radius-md: .375rem;    --spacing-sm: .75rem;    --spacing-lg: 1.5rem  }  :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 {    --spacing-action-x: 1rem;    --color-action: var(--color-surface-raised)  }}@layer utilities {  .text-xs {    font-size: var(--text-xs);    line-height: var(--text-xs--line-height, normal);    letter-spacing: var(--text-xs--letter-spacing, normal)  }  .items-start {    align-items: start  }  .fg-text-muted {    color: var(--color-text-muted)  }  .flex {    display: flex  }  .flex-col {    flex-direction: column  }  .flex-wrap {    flex-wrap: wrap  }  .gap-lg {    gap: var(--spacing-lg)  }  .gap-sm {    gap: var(--spacing-sm)  }  .m\(0\) {    margin: calc(0 * 1px)  }  .px-lg {    padding-inline: var(--spacing-lg)  }}
比較水平內距,再將滑鼠移到各按鈕上,或以 Tab 聚焦。兩者共用操作狀態,只有第二個使用 px-lg。這些按鈕只示範樣式,不會儲存資料。

px-lg 的一般 utility 宣告會優先於元件內距,因為預設集將 utilities 層排在 components 之後。HTML 類別順序不決定勝負。請將區域調整留在第二個按鈕上;只有所有實例都需要時,才放進 .action。競爭宣告請參閱串接層。

一份定義,兩處使用

兩個按鈕共用 .action。輔助線標示水平內距:元件每邊 1rem,使用 px-lg 則為 1.5rem。藍線是註解,不是按鈕樣式。

文字說明
  1. 共用定義. components 層中的原生 .action 規則,為兩個按鈕提供外觀、互動狀態與預設內距。
  2. 第一個實例. class="action" 使用元件左右各 1rem 的水平內距。
  3. 第二個實例. class="action px-lg" 要求左右各 1.5rem 的水平內距。utilities 層排在 components 之後,因此 px-lg 只覆寫此實例的水平內距,其他元件樣式仍然適用。圖中的藍色帶與尺標只標出內距,是檢視註解,不是 utility 新增的樣式。

原生元件規則預設隨樣式表輸出;原生修剪需要明確啟用。將元件狀態寫在 CSS 規則內。原生 .action 定義不會註冊 action:hover utility。

共用外觀與共用傳遞是兩個不同決策。可重用元件導致無關頁面也載入其規則時,請參閱最佳化 CSS 傳遞。

#選擇哪些內容應一起改變

抽出重複的類別清單前,先確認共用的是哪個決策。外觀相似的元素,不一定應一起改變。

共用內容使用方式
跨樣式共用值主題 token
產品角色與狀態原生元件類別
在標記中組合的能力utility
CSS 內的宣告或包裝mixin

第一個範例為操作顏色與內距分別提供 token。其他元件能在 CSS 以 var()、或在標記以 px-action-x 讀取 --spacing-action-x,不必一併採用按鈕背景、形狀與狀態。兩個使用端都應改變時才修改 token;數值只是碰巧相同、用途不同時,請分開定義。主題 token說明命名、別名與區域覆寫。

重複使用的元件,也能將類別清單保留在框架元件內。需要有用的共用樣式契約時,再引入 CSS 名稱;不要只因 class 屬性很長,就將它抽出。

#為樣式能力註冊 utility

假設多個控制項都需要內容置中、帶小間隔的行內排列,但各自保有外觀與間距。只將共用版面配置註冊為 @utility:

相同版面配置,不同控制項

@utility action-row {  display: inline-flex;  align-items: center;  justify-content: center;  gap: var(--spacing-xs);}
產生的 CSS
@layer theme {  :root,  :host {    --color-zinc-5: oklch(96.5% .0011 265);    --color-zinc-30: oklch(78% .0025 265);    --color-zinc-70: oklch(39.8% .0028 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-body: light-dark(var(--color-zinc-70), var(--color-zinc-30));    --color-text-strong: light-dark(var(--color-zinc-100), var(--color-white))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-size-sm: .875rem;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --radius-md: .375rem;    --spacing-xs: .5rem;    --spacing-sm: .75rem;    --spacing-md: 1rem  }  :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))  }}@layer utilities {  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .action-row {    display: inline-flex;    align-items: center;    justify-content: center;    gap: var(--spacing-xs)  }  .items-center {    align-items: center  }  .bg-surface-raised {    background-color: var(--color-surface-raised)  }  .b-line-divider {    border-color: var(--color-line-divider)  }  .r-md {    border-radius: var(--radius-md)  }  .b-solid {    border-style: solid  }  .b\(1\) {    border-width: calc(1 * 1px)  }  .shadow-sm {    box-shadow: var(--shadow-sm)  }  .fg-text-body {    color: var(--color-text-body)  }  .fg-text-strong {    color: var(--color-text-strong)  }  .flex {    display: flex  }  .flex-wrap {    flex-wrap: wrap  }  .gap-md {    gap: var(--spacing-md)  }  .py-xs {    padding-block: var(--spacing-xs)  }  .px-md {    padding-inline: var(--spacing-md)  }  .px-sm {    padding-inline: var(--spacing-sm)  }  .underline {    text-decoration-line: underline  }  .shadow-md\:hover:hover {    box-shadow: var(--shadow-md)  }  .fg-text-strong\:hover:hover {    color: var(--color-text-strong)  }  .bg-surface-inset\:active:active {    background-color: var(--color-surface-inset)  }}
action-row 讓兩個控制項具有相同對齊與間隔;其他類別提供外觀。Add note 是樣式範例;Choose tokens 會開啟主題指南。

完整類別提出請求時,utility 才會產生規則。它也接受與預設集 utility 相同的選擇器和條件後綴,例如 action-row@sm。名稱描述版面配置能力,不會規定整個按鈕的樣式。

預設集既有 utility 已能表達共用決策時,直接使用即可。例如,只改間隔需要的是 gap-sm 或 gap(8),無須另一個自訂 utility。多個宣告形成呼叫端應一起重用的能力時,再定義組合樣式。Utility 契約定義了註冊與替換方式。

#以 mixin 重用 CSS

原生 CSS 規則若需要相同的橫列配置,請定義 mixin 並以 @apply 展開。此範例讓操作按鈕與工具列連結保留不同元件角色,同時共用對齊方式:

@mixin --action-row {  display: inline-flex;  align-items: center;  justify-content: center;  gap: var(--spacing-xs);}@layer components {  .action {    @apply --action-row;    padding: .5rem 1rem;    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-md);    background-color: var(--color-surface-raised);    color: var(--color-text-strong);    box-shadow: var(--shadow-sm);    font: inherit;    font-size: var(--font-size-sm);  }  .toolbar-link {    @apply --action-row;    color: var(--color-text-body);    font: inherit;    font-size: var(--font-size-sm);    text-decoration-line: underline;  }}
<button type="button" class="action"><span aria-hidden="true">+</span> Add note</button><a href="/guide/theme" class="toolbar-link">Choose tokens <span aria-hidden="true">→</span></a>
產生的 CSS
@layer components {  .action {    display: inline-flex;    align-items: center;    justify-content: center;    gap: var(--spacing-xs);    padding: .5rem 1rem;    border: 1px solid var(--color-line-divider);    border-radius: var(--radius-md);    background-color: var(--color-surface-raised);    color: var(--color-text-strong);    box-shadow: var(--shadow-sm);    font: inherit;    font-size: var(--font-size-sm)  }  .toolbar-link {    display: inline-flex;    align-items: center;    justify-content: center;    gap: var(--spacing-xs);    color: var(--color-text-body);    font: inherit;    font-size: var(--font-size-sm);    text-decoration-line: underline  }}@layer theme {  :root,  :host {    --color-zinc-30: oklch(78% .0025 265);    --color-zinc-70: oklch(39.8% .0028 265);    --color-zinc-90: oklch(24.6% .0022 265);    --color-zinc-100: oklch(15% .0016 265)  }  :root,  :host {    --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-body: light-dark(var(--color-zinc-70), var(--color-zinc-30));    --color-text-strong: light-dark(var(--color-zinc-100), var(--color-white))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-size-sm: .875rem;    --radius-md: .375rem;    --spacing-xs: .5rem  }  :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))  }}

開啟產生的 CSS:橫列宣告會出現在兩條原生規則中,而各規則保有其餘獨立宣告。定義 --action-row 不會建立標記類別。@apply 指定的是 mixin,不接受 utility 類別清單。

CSS 與標記都需要相同能力時,將 mixin 保留為宣告來源,再加入呼叫它的 utility:

@utility action-row {  @apply --action-row;}

將此定義加入 mixin 範例的樣式表。它取代先前獨立的 utility 主體:修改 --action-row,現在就會同時影響原生元件與 utility。請依全域樣式將這些定義整理到應用程式進入點圖。

utility 擁有類別;mixin 填入選擇器

方框代表 CSS 產物。@apply 展開不會註冊另一個標記類別。

文字說明

請求 stack utility 會輸出帶 display:grid 的 .stack。在 .card 內套用 --stack mixin,會在 .card 下輸出相同宣告;選擇器由呼叫端擁有。

#透過 contents 共用包裝

有時重複的是包住不同宣告的條件。懸停與鍵盤焦點 mixin 可以提供選擇器,再由各呼叫端透過 @contents 提供樣式:

一個互動包裝,兩處呼叫

@mixin --hocus {  &:is(:hover, :focus-visible) {    @contents;  }}@layer components {  .action {    @apply --hocus {      text-decoration-line: underline;    }  }}
產生的 CSS
@layer components {  .action:is(:hover, :focus-visible) {    text-decoration-line: underline  }}@layer theme {  :root,  :host {    --color-zinc-90: oklch(24.6% .0022 265);    --color-zinc-100: oklch(15% .0016 265)  }  :root,  :host {    --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))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-size-sm: .875rem;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --radius-md: .375rem;    --spacing-xs: .5rem;    --spacing-md: 1rem  }  :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))  }}@layer utilities {  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .underline\@apply\(--hocus\):is(:hover, :focus-visible) {    text-decoration-line: underline  }  .bg-surface-raised {    background-color: var(--color-surface-raised)  }  .b-line-divider {    border-color: var(--color-line-divider)  }  .r-md {    border-radius: var(--radius-md)  }  .b-solid {    border-style: solid  }  .b\(1\) {    border-width: calc(1 * 1px)  }  .shadow-sm {    box-shadow: var(--shadow-sm)  }  .fg-text-strong {    color: var(--color-text-strong)  }  .flex {    display: flex  }  .flex-wrap {    flex-wrap: wrap  }  .gap-md {    gap: var(--spacing-md)  }  .outline-offset\(3\) {    outline-offset: calc(3 * 1px)  }  .py-xs {    padding-block: var(--spacing-xs)  }  .px-md {    padding-inline: var(--spacing-md)  }  .outline-solid\:focus-visible:focus-visible {    outline-style: solid  }  .outline\(2\)\:focus-visible:focus-visible {    outline-width: calc(2 * 1px)  }}
懸停或以 Tab 聚焦任一按鈕,標籤就會出現底線。第一個在原生 CSS 內提供宣告,第二個包裝 underline utility。焦點外框另行指定。

CSS 中,@apply --hocus 後的區塊填入 @contents。標記中,@apply(--hocus) 包住該類別產生的宣告。包裝不會註冊 token,也不會將相鄰類別收進主體。要重用一組宣告,使用 utility 或 mixin;呼叫端需自行選擇共用選擇器或條件內的內容時,才使用 contents。轉送與備援行為請參閱 mixin contents。

#呼叫端需要時才開放參數

共用控制項尺寸可能牽動多個屬性。若分開的預設集 utility 會讓呼叫端重複計算,請以參數表達這個關係:

一個尺寸控制高度與內距

@utility action-size(--height: 2.5rem) {  min-height: var(--height);  padding-inline: calc(var(--height) / 2);}
產生的 CSS
@layer theme {  :root,  :host {    --color-zinc-90: oklch(24.6% .0022 265);    --color-zinc-100: oklch(15% .0016 265)  }  :root,  :host {    --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))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-size-sm: .875rem;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --radius-md: .375rem;    --spacing-md: 1rem  }  :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))  }}@layer utilities {  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .action-size\(\) {    min-height: 2.5rem;    padding-inline: calc(2.5rem / 2)  }  .action-size\(3rem\) {    min-height: 3rem;    padding-inline: calc(3rem / 2)  }  .items-center {    align-items: center  }  .bg-surface-raised {    background-color: var(--color-surface-raised)  }  .b-line-divider {    border-color: var(--color-line-divider)  }  .r-md {    border-radius: var(--radius-md)  }  .b-solid {    border-style: solid  }  .b\(1\) {    border-width: calc(1 * 1px)  }  .shadow-sm {    box-shadow: var(--shadow-sm)  }  .fg-text-strong {    color: var(--color-text-strong)  }  .flex {    display: flex  }  .flex-wrap {    flex-wrap: wrap  }  .gap-md {    gap: var(--spacing-md)  }}
action-size() 使用 2.5rem;action-size(3rem) 使用 3rem。最小高度與水平內距都跟隨引數。這些按鈕只示範尺寸。

一份定義,各自傳入值

根字型為 16px 時,最小高度為 40px 與 48px;padding-inline 為所選高度的一半。醒目的內縮區域是量測輔助線。

文字說明

相同 action-size utility 分別以預設 2.5rem 與 3rem 呼叫。第二個控制項具有較大最小高度與水平內距,兩者仍保有自己的引數。

空呼叫使用宣告的預設值。引數必須在編譯時已知;選擇尺寸時,請使用完整類別字串。會隨 JavaScript 連續變動的值,則應放進原生 CSS 自訂屬性。參數契約說明接受的型別與限制。

變更共用定義前,至少比較兩處呼叫,包含一個有區域覆寫的實例。檢查懸停、鍵盤狀態與產生的 CSS。若呼叫端不再需要相同行為,請拆開定義,不要在每個實例追加例外。跨專案共用穩定詞彙時,請接著閱讀撰寫套件。