HTML 自訂資料屬性(data-*)
前言
最近在使用 shadcn/ui 做專案時,我在元件原始碼一直看到一個很特別的屬性寫法:data-*,而且它們常常和 Tailwind class 寫在一起,看起來甚至會直接決定動畫方向、側邊欄收合狀態、元件內部某個區塊的樣式,比方說類似這樣的程式碼:
<div data-state="open" data-slot="sidebar" className="data-[state=open]:animate-in group-data-[collapsible=icon]:hidden">
...
</div>
這讓我產生一個疑問:這些 data-* 屬性到底是 HTML 標準的一部分,還是 shadcn/ui 或 Tailwind 自己發明的語法?
data-*:HTML 留給開發者的標準延展空間
事實上 data-* 並不是某個 UI library 自創的命名慣例,而是 HTML 標準提供給開發者使用的 Custom Data Attributes(自訂資料屬性)。
如上圖所示:同一個 HTML element 可以同時擁有很多種 attribute,但它們不應該全部承擔同一種任務。id 偏向唯一定位,class 偏向樣式分類,aria-* 偏向輔助科技語意,而 data-* 最基礎的概念則是讓開發者把「和這個元素有關,但 HTML 沒有內建語意」的私有資訊附加在元素上。
為什麼 HTML 需要自訂資料屬性
HTML 本身有一套標準 attribute,例如:
<img>的src、alt<input>的type、value<button>的disabled
這些 attribute 都有瀏覽器理解的標準行為。問題是,在真實開發裡,元件常常需要一些 HTML 標準沒有定義的資訊。例如:
- 一個可展開的面板可能需要標記目前是
open還是closed - 一個浮動選單可能需要知道自己目前出現在觸發按鈕的
top還是bottom - 一個卡片元件可能希望 DOM 上可以看出哪個節點是
card-header,哪個節點是card-content
這些資訊對應的是應用程式或 UI library 自己的狀態,HTML 標準不可能替所有框架與 所有產品預先定義好。
在沒有 data-* 的世界裡,開發者可能會開始亂加非標準 attribute:
<div state="open" slot-name="card-header" sidebar-mode="icon">
...
</div>
這種寫法最大的問題是:HTML parser 雖然可能容忍它,但它不是一個清楚的標準約定。下一個讀程式的人不知道哪些 attribute 是 HTML 內建,哪些是專案自訂;工具、linter、型別檢查也比較難辨識。data-* 的價值,就是把這些自訂資訊放進一個被標準承認的命名空間裡:
<div data-state="open" data-slot="card-header" data-sidebar="menu">
...
</div>
只要 attribute 名稱以 data- 開頭,它就明確表示:這是開發者附加在元素上的私有資料,不是瀏覽器內建行為的一部分。
data-* 不是語意化 HTML 的替代品如果 HTML 本身已經有正確的元素或 attribute,就應該優先使用標準語意。例如按鈕的不可用狀態應該使用 disabled,展開狀態若會影響輔助科技理解,也常常需要搭配 aria-expanded。data-* 比較適合補充元件內部狀態、樣式鉤子或測試定位,不適合拿來偽裝成瀏覽器本來就懂的語意。
基本語法與命名規則
data-* 的基本語法很單純:attribute 名稱必須以 data- 開頭,後面接自訂名稱。
<article
data-post-id="42"
data-state="published"
data-layout="compact"
>
...
</article>
我在整理時會把它拆成兩層來看:
| 位置 | 範例 | 說明 |
|---|---|---|
| attribute name | data-post-id | 給 DOM、CSS selector、dataset 辨識的名稱 |
| attribute value | "42"、"published" | 寫在 HTML attribute 裡的值,本質上是字串 |
這裡最容易忽略的是「值本質上是字串」。即使寫的是 data-count="3",透過 DOM 讀出來也會是 "3",不是數字 3。如果寫 data-active="false",它也只是字串 "false",不會自動變成 boolean false。
const el = document.querySelector("[data-count]");
el.dataset.count; // "3"
typeof el.dataset.count; // "string"
命名上,我會優先使用小寫與 dash 分隔,例如 data-user-id、data-state、data-sidebar-mode。這樣寫有兩個好處:
- 第一,它符合 HTML attribute 常見的命名節奏
- 第二,透過 JavaScript
dataset讀取時,dash 分隔會自然轉成 camelCase,對應關係很清楚
<button data-user-id="bosh" data-menu-state="open">
編輯
</button>
const button = document.querySelector("button");
button.dataset.userId; // "bosh"
button.dataset.menuState; // "open"
dataset:JavaScript 如何讀寫 data-*
JavaScript 讀取 data-* 有兩種常見方式。
第一種是通用的 DOM API:getAttribute()。
const menu = document.querySelector("[data-state]");
menu.getAttribute("data-state"); // "open"
第二種是 HTML 專門為 data-* 提供的 dataset。dataset 會回傳一個 DOMStringMap,把所有 data-* 集中成一個物件介面。
const menu = document.querySelector("[data-state]");
menu.dataset.state; // "open"
如果 attribute 名稱裡有 dash,dataset 會把 data- 後面的名稱轉成 camelCase:
<div data-index-number="123" data-user-role="admin"></div>
const el = document.querySelector("div");
el.dataset.indexNumber; // "123"
el.dataset.userRole; // "admin"
寫入也一樣。當程式設定 dataset 的值時,瀏覽器會同步更新 DOM attribute。
el.dataset.state = "closed";
// DOM 會變成:
// <div data-state="closed"></div>
這個同步能力讓 data-* 很適合成為 JavaScript 與 CSS 之間的橋樑。JavaScript 負責改變狀態,CSS 負責根據 attribute 套用樣式。也正是這個橋樑,後面會延伸出 Tailwind data variants 與 shadcn/ui 的設計方式。
data-* 如何和 CSS 連動
前面先釐清了 data-* 是 HTML 標準提供的延展空間,但這還不足以解釋它為什麼會在 UI library 裡變得這麼重要。關鍵在於:data-* 不只是 JavaScript 可以讀,它也是普通 HTML attribute,所以 CSS 可以直接用 attribute selector 選到它。
這張圖可以把 data-* 的角色串起來:HTML 負責承載狀態,JavaScript 可以透過 dataset 讀寫,CSS 可以透過 attribute selector 套樣式,而 Tailwind 只是把這件事包成更適合 utility class 的語法。
Attribute Selector:用屬性狀態選取元素
CSS attribute selector 的基本語法是用中括號選取具有某個 attribute 的元素。這部分和之前整理的 CSS 選擇器 Cheat Sheet 是同一個基礎。
[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
這段 CSS 的意思是:只要元素身上存在 data-disabled attribute,就套用這組樣式。不需要檢查值,存在就算符合。
如果需要判斷特定值,可以寫成:
[data-state="open"] {
opacity: 1;
transform: scale(1);
}
[data-state="closed"] {
opacity: 0;
transform: scale(0.96);
}
這種寫法和用 class 控制樣式很像,但語意不同。class="open" 表面上看起來是在描述樣式分類,data-state="open" 則是在描述這個元素目前的狀態。CSS 只是剛好根據這個狀態做出視覺反應。
<div class="popover" data-state="open">
...
</div>
.popover {
transition: opacity 150ms ease, transform 150ms ease;
}
.popover[data-state="open"] {
opacity: 1;
transform: translateY(0);
}
.popover[data-state="closed"] {
opacity: 0;
transform: translateY(-4px);
}
當 class 名稱開始變成 is-open、is-disabled、is-error 時,通常就代表它其實不是純樣式分類,而是在描述狀態。這時改用 data-state、data-disabled、data-error,DOM 會更像是在宣告事實,而不是宣告某段 CSS class 應該被套用。