重用樣式
依哪些內容應一起改變,選擇 token、元件類別、utility 與 mixin,同時保留容易表達的區域變化。
本頁目錄
兩個畫面可以共用儲存按鈕,又讓其中一個實例保有較大間距。將按鈕的共用外觀放進元件類別,區域調整則留在標記中。更小尺度也適用相同方法:多個使用端應一起改變時,為值命名;多條規則需要同一段 CSS 時,就共用那段 CSS。
請先完成安裝與語法教學。下方每個範例都提供自己的 CSS 與標記。將 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; } }}<div class="flex flex-wrap items-start gap-lg"> <div class="flex flex-col items-start gap-sm"> <p class="m(0) text-xs fg-text-muted">Shared component</p> <button type="button" class="action">Save changes</button> </div> <div class="flex flex-col items-start gap-sm"> <p class="m(0) text-xs fg-text-muted">Local adjustment</p> <button type="button" class="action px-lg">Save changes</button> </div></div>產生的 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) }}px-lg 的一般 utility 宣告會優先於元件內距,因為預設集將 utilities 層排在 components 之後。HTML 類別順序不決定勝負。請將區域調整留在第二個按鈕上;只有所有實例都需要時,才放進 .action。競爭宣告請參閱串接層。
一份定義,兩處使用
兩個按鈕共用 .action。輔助線標示水平內距:元件每邊 1rem,使用 px-lg 則為 1.5rem。藍線是註解,不是按鈕樣式。
文字說明
- 共用定義. components 層中的原生 .action 規則,為兩個按鈕提供外觀、互動狀態與預設內距。
- 第一個實例. class="action" 使用元件左右各 1rem 的水平內距。
- 第二個實例. 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);}<div class="flex flex-wrap items-center gap-md"> <button type="button" class="action-row px-md py-xs text-sm r-md b(1) b-solid b-line-divider bg-surface-raised fg-text-strong shadow-sm shadow-md:hover bg-surface-inset:active"> <span aria-hidden="true">+</span>Add note</button> <a href="/guide/theme" target="_top" class="action-row px-sm py-xs text-sm fg-text-body fg-text-strong:hover underline">Choose tokens <span aria-hidden="true">→</span> </a></div>產生的 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) }}完整類別提出請求時,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; } }}<div class="flex flex-wrap gap-md"> <button type="button" class="action px-md py-xs text-sm r-md b(1) b-solid b-line-divider bg-surface-raised fg-text-strong shadow-sm outline(2):focus-visible outline-solid:focus-visible outline-offset(3)">CSS call site</button> <button type="button" class="px-md py-xs text-sm r-md b(1) b-solid b-line-divider bg-surface-raised fg-text-strong shadow-sm underline@apply(--hocus) outline(2):focus-visible outline-solid:focus-visible outline-offset(3)">Class call site</button></div>產生的 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) }}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);}<div class="flex flex-wrap items-center gap-md"> <button type="button" class="action-size() text-sm r-md b(1) b-solid b-line-divider bg-surface-raised fg-text-strong shadow-sm">Default size</button> <button type="button" class="action-size(3rem) text-sm r-md b(1) b-solid b-line-divider bg-surface-raised fg-text-strong shadow-sm">Larger size</button></div>產生的 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) }}一份定義,各自傳入值
根字型為 16px 時,最小高度為 40px 與 48px;padding-inline 為所選高度的一半。醒目的內縮區域是量測輔助線。
文字說明
相同 action-size utility 分別以預設 2.5rem 與 3rem 呼叫。第二個控制項具有較大最小高度與水平內距,兩者仍保有自己的引數。
空呼叫使用宣告的預設值。引數必須在編譯時已知;選擇尺寸時,請使用完整類別字串。會隨 JavaScript 連續變動的值,則應放進原生 CSS 自訂屬性。參數契約說明接受的型別與限制。
變更共用定義前,至少比較兩處呼叫,包含一個有區域覆寫的實例。檢查懸停、鍵盤狀態與產生的 CSS。若呼叫端不再需要相同行為,請拆開定義,不要在每個實例追加例外。跨專案共用穩定詞彙時,請接著閱讀撰寫套件。





