运行时 CSS 自定义属性:主题定制、JS 桥接与 @property
了解 CSS 自定义属性与 Sass 变量的区别,并将其用于主题设计、JavaScript 驱动的动画效果、流体字体、组件变体以及类型化动画。
来源: CSS Variables Are Underrated — Here’s What You’re Missing
许多代码库使用 CSS 自定义属性的方式与 Sass 变量极为相似:在 :root 中声明一次颜色列表,之后便不再修改。虽然这种方式可行,但却忽略了两者之间的本质差异。自定义属性是浏览器中的实时值,能够通过 DOM 继承,响应媒体查询和属性变化,并且可以随时通过 JavaScript 进行更新。本指南将展示这种运行时特性在实践中的应用,从主题定制和参数化组件到使用 @property 实现的类型化动画,并指出可能让团队遇到麻烦的机制与陷阱。
编译时常量与实时值
Sass、Less 和 Stylus 等预处理器多年来一直通过嵌套、混合函数和变量来塑造前端样式。当浏览器支持原生自定义属性时,人们的普遍反应是这些属性并没有提供 $primary-color 已经具备的功能。正是这种假设导致了它们的使用率低下。两者的区别在于值的存在形式:
+-------------------------------------------------------------------+
| SASS / SCSS VARIABLES |
| Compile-Time Constants --> Evaluated Before Ship --> Static |
+-------------------------------------------------------------------+
+-------------------------------------------------------------------+
| CSS CUSTOM PROPERTIES |
| Runtime Live Properties --> Evaluated in Browser --> Dynamic |
+-------------------------------------------------------------------+
预处理器变量最终会变成什么
Sass 变量会在 CSS 到达浏览器之前就被解析。在下面的 SCSS 代码中,第二个值是在 dark 修改器块内部赋值的:
$brand-color: #3b82f6;
.card {
background-color: $brand-color;
}
.card--dark {
$brand-color: #1e293b;
background-color: $brand-color;
}
编译器会在变量出现的所有位置替换为实际值,因此最终生成的样式表内容仅为此:
.card {
background-color: #3b82f6;
}
.card--dark {
background-color: #1e293b;
}
编译完成后,$brand-color 这个名称就不再存在了。浏览器无法知道这两个颜色其实来自同一个变量,也无法在用户点击、视图窗口变化或悬停时修改该值。因此每种变体都必须提前作为额外的 CSS 规则来生成。
自定义属性的保留机制
原生版本会在浏览器中保持该名称的有效性:
:root {
--brand-color: #3b82f6;
}
.card {
background-color: var(--brand-color);
}
.card[data-theme="dark"] {
--brand-color: #1e293b;
}
此处,--brand-color 是每个元素计算样式的一部分。当某个 .card 被赋予 data-theme="dark" 属性时,浏览器会重新计算该卡片及其子元素的 var(--brand-color) 值。无需复制 .card 的规则,只需修改 DOM 树中某一分支的对应值即可。简而言之:Sass 变量在构建过程中会被替换,运行时便不再存在;而自定义属性则由浏览器为每个元素单独解析,会参与样式级联,并且在页面加载后仍可更改。
需要掌握的机制
关于语法、作用域和错误处理的一些规则,可以解释人们遇到的大多数意外行为。
命名与读取值
自定义属性的名称必须以两个连字符开头,且名称区分大小写,因此这是两个独立的属性:
:root {
--main-color: #10b981;
--Main-Color: #ef4444; /* Different property! */
}
你使用 var() 函数读取了一个值:
.button {
background-color: var(--main-color);
}
作用域遵循级联规则
自定义属性也遵循常规的级联和继承机制。在与 <html> 元素对应的 :root 上声明的属性可在整个页面中使用;而在其他选择器上声明的属性则仅对该元素及其后代有效:
/* Global Scope */
:root {
--spacing-unit: 1rem;
}
/* Sub-tree Scope Override */
.sidebar {
--spacing-unit: 0.5rem; /* Overrides --spacing-unit for .sidebar and its descendants */
}
.sidebar .widget {
padding: var(--spacing-unit); /* Computes to 0.5rem */
}
.main-content .widget {
padding: var(--spacing-unit); /* Computes to 1rem */
}
这两个组件使用相同的规则,但由于最近声明的规则优先生效,位于 .sidebar 内部的组件获得的间距更小。这正是作用域内覆盖如此高效的原因:你只需修改数值,而无需更改规则。
回退值
var() 接受第二个参数,当所引用的属性没有值时就会使用该参数。回退值本身也可以包含 var() 调用,因此可以将其串联使用:
.avatar {
/* Fallback to standard 40px if --avatar-size is undefined */
width: var(--avatar-size, 40px);
/* Nested fallbacks */
color: var(--user-theme-color, var(--brand-default, #2563eb));
}
需明确指定何时使用回退机制。只有当该元素未定义自定义属性(也未从父元素继承)或显式设置为 initial 时才会使用此机制。如果属性虽已定义,但其值对目标元素而言毫无意义——例如期望颜色却得到了长度值——则回退机制将被忽略。需要注意的是,unset 并非触发回退的可靠方式,因为自定义属性默认会继承,所以 unset 的行为与 inherit 相同,会在存在父元素值时使用该值。
在计算属性值时无效
包含无效值的普通声明会在解析阶段被丢弃,因此之前有效的声明仍然有效。含有 var() 的声明在解析时无法被检查,因为要等到级联处理完成才能知道其替换后的值:
.box {
color: red;
color: var(--not-a-color, 12px); /* Invalid type for color! */
}
解析器会接受第二行color属性值,并将其覆盖为color: red。直到计算样式值时,浏览器才会发现12px并非颜色值。此时之前的声明早已失效,该属性的表现就如同被unset了一般:像color这样的继承属性会采用父元素的值,而非继承的属性则恢复为初始值(例如background-color的初始值为transparent)。这种隐性的错误正是稍后要介绍的@property机制旨在解决的问题之一。
避免规则重复以实现主题切换
传统的深色模式实现方式会复制每一条主题规则:
/* Traditional approach: Duplicating declarations */
.card {
background-color: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
body.dark-mode .card {
background-color: #0f172a;
color: #f8fafc;
border: 1px solid #334155;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}
如果组件数量较少,通过设置少量属性即可应对。但当有数十个组件,每个组件还包含大量主题相关声明时,每种主题都会让需要保持同步的选择器数量呈指数级增长。
令牌使组件与主题无关
如果设计决策都存储在自定义属性中,组件规则就无需再关心当前使用的是哪种主题了:
/* 1. Define Design Tokens */
:root {
--bg-surface: #ffffff;
--text-primary: #1e293b;
--border-subtle: #e2e8f0;
--shadow-alpha: 0.05;
}
[data-theme="dark"] {
--bg-surface: #0f172a;
--text-primary: #f8fafc;
--border-subtle: #334155;
--shadow-alpha: 0.5;
}
/* 2. Write Component CSS Once */
.card {
background-color: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
box-shadow: 0 4px 6px rgba(0, 0, 0, var(--shadow-alpha));
}
.card规则只需定义一次。若要添加第三种主题,比如[data-theme="high-contrast"],只需提供四个令牌值即可,而无需为每个组件重新声明规则。
从颜色通道生成调色板
你可以进一步将颜色的各个通道(例如以OKLCH格式存储)保存下来,然后从中计算出所有颜色变体:
:root {
/* Store raw OKLCH channels: Lightness, Chromaticity, Hue */
--brand-l: 0.62;
--brand-c: 0.19;
--brand-h: 255;
/* Base dynamic color */
--brand-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));
/* Automatically derived variations */
--brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
--brand-active: oklch(calc(var(--brand-l) - 0.2) var(--brand-c) var(--brand-h));
--brand-tint: oklch(0.95 calc(var(--brand-c) * 0.2) var(--brand-h));
--brand-alpha-50: oklch(var(--brand-l) var(--brand-c) var(--brand-h) / 0.5);
}
.button-primary {
background-color: var(--brand-color);
color: white;
}
.button-primary:hover {
background-color: var(--brand-hover);
}
.button-primary:active {
background-color: var(--brand-active);
}
.badge {
background-color: var(--brand-tint);
color: var(--brand-color);
}
悬停、激活、色调调整以及半透明等样式变体均依赖于三个数值。通过 JavaScript 或用户偏好设置修改 :root 中的 --brand-h,即可在无需使用任何颜色处理库的情况下,统一更改基础颜色、其各种状态、色调以及叠加层的外观。
所有派生值都存在一个细微特点:自定义属性内的 var() 会在该属性声明所在的元素上被解析,其后代元素会继承由此产生的值。由于 --brand-color 及其他相关属性都是在 :root 中声明的,因此在层级更深的部位覆盖 --brand-h 时并不会重新计算这些属性的值。要么在 :root 中修改对应值,要么在覆盖该值的范围内重新声明这些派生值。
从 JavaScript 控制 CSS
自定义属性为脚本与样式之间提供了简洁的接口。无需编写内联样式字符串或处理复杂的类组合,JavaScript可以将指针坐标或应用程序状态等原始值传递给CSS,由样式表决定如何处理这些数据。
跟随光标的高亮效果
首先使用卡片元素:
<div class="glow-card" id="card">
<h3>Interactive Reactivity</h3>
<p>Hover over this card to see real-time cursor lighting.</p>
</div>
样式表定义了默认坐标,并利用这些坐标在伪元素中定位径向渐变:
.glow-card {
--mouse-x: 50%;
--mouse-y: 50%;
position: relative;
background-color: #1e1e2e;
border-radius: 12px;
padding: 2rem;
color: #cdd6f4;
overflow: hidden;
}
/* Radial light overlay following cursor coordinates */
.glow-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
800px circle at var(--mouse-x) var(--mouse-y),
rgba(255, 255, 255, 0.08),
transparent 40%
);
pointer-events: none;
}
脚本仅负责报告指针相对于卡片的所在位置:
const card = document.getElementById('card');
card.addEventListener('pointermove', (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Send raw coordinates to CSS
card.style.setProperty('--mouse-x', `${x}px`);
card.style.setProperty('--mouse-y', `${y}px`);
});
这种分工方式的优势在于:JavaScript只需在一个元素上写入两个数值,而无需直接操作渐变效果本身。不过这并非完全没有代价——每次更新都会触发卡片样式的重新计算,由于渐变发生了变化,覆盖层也会被重新绘制。对于单个元素而言,这种开销很小,因此该效果通常能够流畅运行,但它并非在主线程之外完成的“零重排”操作。
滚动进度条
同样的思路可以将滚动位置以0到1之间的无量纲分数形式传递给CSS:
window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = (scrollTop / docHeight);
// Set unitless fraction (0 to 1)
document.documentElement.style.setProperty('--scroll-progress', scrollPercent);
});
该进度条通过transform来实现功能,这样可以避免布局计算,因为缩放操作不会改变页面上其他元素的尺寸:
/* Progress indicator bar */
.scroll-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 100%;
background-color: #3b82f6;
transform-origin: left;
/* Hardware-accelerated GPU transform using variable */
transform: scaleX(var(--scroll-progress, 0));
will-change: transform;
}
有两点改进值得考虑。该脚本将内容写入document.documentElement,而这正是下方性能部分所警告的那种全局性、高频更新的写法;若将数值直接设置在该进度条元素上,则可限制重新计算仅作用于该单个元素。此外,在支持CSS滚动驱动动画的浏览器中,完全可以利用animation-timeline: scroll()来创建此类进度条,而无需任何JavaScript代码。
流动式排版与比例控制
基于断点的排版方式会在768px和1024px等宽度点之间切换固定尺寸,而将自定义属性与clamp()及calc()结合使用,则能让尺寸在最小值与最大值之间连续变化。
参数化的clamp()计算方式
其思路是用简单的数字(视口范围和字体大小范围)来描述比例,进而求出两者之间线性函数的斜率和截距:
:root {
/* Viewport and layout parameters */
--fluid-min-width: 320;
--fluid-max-width: 1200;
/* Typography scale parameters */
--font-min-size: 16;
--font-max-size: 20;
/* Compute slope and intersection for clamp() */
--slope: (var(--font-max-size) - var(--font-min-size)) / (var(--fluid-max-width) - var(--fluid-min-width));
--y-axis-intersection: -1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size);
/* Fluid base font size expression */
--fluid-type-base: clamp(
calc(var(--font-min-size) * 1px),
calc(var(--y-axis-intersection) * 1px + (var(--slope) * 100vw)),
calc(var(--font-max-size) * 1px)
);
}
body {
font-size: var(--fluid-type-base);
}
请将此代码片段视为起点而非可直接使用的代码。自定义属性替换是文本形式的,因此当 --y-axis-intersection 被插入到 calc(var(--y-axis-intersection) * 1px + ...) 中时,* 1px 只会作用于该表达式的最后一项。没有单位的第一个项会被加到长度值上,从而导致整个 clamp() 函数失效。例如,通过将中间表达式用括号括起来,如 --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size));,或放在 calc() 中,就能保持预期的分组方式。
基于单一比例的和谐标题大小
确定了基础尺寸后,只需一个比例即可生成整个标题层级结构:
:root {
/* Scale multiplier ratio (e.g., 1.25 Major Third) */
--type-ratio: 1.25;
--step-0: var(--fluid-type-base);
--step-1: calc(var(--step-0) * var(--type-ratio));
--step-2: calc(var(--step-1) * var(--type-ratio));
--step-3: calc(var(--step-2) * var(--type-ratio));
--step-4: calc(var(--step-3) * var(--type-ratio));
}
/* Application across elements */
p { font-size: var(--step-0); }
h3 { font-size: var(--step-1); }
h2 { font-size: var(--step-2); }
h1 { font-size: var(--step-3); }
每一步都会乘以前一步的结果,从而保持各级别之间的比例一致。在媒体查询中更改该比例会同时重新计算所有层级:
@media (min-width: 1024px) {
:root {
/* Upgrade to 1.333 Perfect Fourth on large displays */
--type-ratio: 1.333;
}
}
这种方法可行是因为步骤和比例都是在同一个元素 :root 上声明的,因此在计算步骤时就能看到覆盖效果。
使用参数而非变体类的组件
BEM或类似方法中的变体类往往会导致内容倍增:每种尺寸、形状和颜色都会形成另一组部分重复基础结构的声明。
/* Traditional class bloat */
.btn { padding: 8px 16px; font-size: 14px; background: blue; border-radius: 4px; }
.btn--md { padding: 12px 24px; font-size: 16px; }
.btn--lg { padding: 16px 32px; font-size: 18px; }
.btn--rounded { border-radius: 9999px; }
.btn--danger { background: red; }
.btn--success { background: green; }
将自定义属性作为组件的API
一种更为稳健的方法是将自定义属性视为组件的配置。基本规则会读取带有默认值的属性中的所有可调整值,而修饰符仅用于修改参数:
.btn {
/* 1. Component Defaults */
--btn-bg: #3b82f6;
--btn-color: #ffffff;
--btn-px: 1.25rem;
--btn-py: 0.625rem;
--btn-radius: 0.375rem;
--btn-font-size: 0.875rem;
/* 2. Unified Rule Declaration */
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--btn-py) var(--btn-px);
background-color: var(--btn-bg);
color: var(--btn-color);
border-radius: var(--btn-radius);
font-size: var(--btn-font-size);
border: none;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover {
filter: brightness(0.9);
}
/* 3. Concise Declarative Modifiers */
.btn--lg {
--btn-px: 1.75rem;
--btn-py: 0.875rem;
--btn-font-size: 1.125rem;
}
.btn--pill {
--btn-radius: 9999px;
}
.btn--danger {
--btn-bg: #ef4444;
}
.btn--ghost {
--btn-bg: transparent;
--btn-color: #3b82f6;
}
诸如 .btn--lg 这样的修饰符不会重复设置 display、border-radius、cursor 或 transition 等属性,它们仅为主参数设置新值。这样一来样式表规模更小,修饰符之间可以自然组合(btn--lg btn--pill btn--danger 这种写法完全可行),而且当某个变体显示异常时,只需检查少数几个修饰符即可找到问题所在。
类型化属性与 @property 实现的动画
未注册的自定义属性存在一个众所周知的局限:它们无法在过渡效果或关键帧中被插值使用。浏览器会将这些属性的值视为无类型的标记序列,因此无法实现从 0deg 到 360deg 或从 red 到 blue 的动画效果;在动画过程中,该值只会直接在某个时刻发生切换。
@property at规则可以通过指定语法、初始值以及继承设置来注册自定义属性。目前主流浏览器的最新版本都支持该功能,但在使用之前请先确认目标浏览器是否具备相应支持。
注册带类型的属性
/* Register type-checked custom property */
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --accent-color {
syntax: "<color>";
inherits: true;
initial-value: #3b82f6;
}
通过注册还可以解决之前存在的隐性错误问题:与声明的语法不匹配的值会被拒绝,此时属性会回退到其注册的初始值,而不会使用意外的继承值。
渐变过渡效果
一旦浏览器识别出--gradient-angle表示角度而--accent-color表示颜色,它就可以对二者进行插值处理,从而实现渐变过渡效果:
.animated-gradient-card {
/* Reference the typed property inside radial/linear gradient */
background: linear-gradient(
var(--gradient-angle),
#0f172a,
var(--accent-color),
#0f172a
);
border-radius: 16px;
padding: 3rem;
/* Animate custom property on hover! */
transition: --gradient-angle 0.8s ease, --accent-color 0.5s ease;
}
.animated-gradient-card:hover {
--gradient-angle: 180deg;
--accent-color: #ec4899;
}
连续关键帧动画
同样的机制使得关键帧能够让渐变无限旋转:
@keyframes spin-gradient {
to {
--gradient-angle: 360deg;
}
}
.glowing-border {
animation: spin-gradient 4s linear infinite;
}
需注意,对用于渐变的已注册属性进行动画处理时,仍会在主线程的每一帧重新绘制该元素;这与仅通过合成器实现的transform或opacity动画不同。
性能:更新带来的影响
自定义属性本身并不慢,其性能开销取决于更新会影响到DOM树中的多少部分。
更新的传播范围
调用 element.style.setProperty('--name', value) 会使得该元素及其所有可能继承其样式的元素失去有效样式:
+-------------------------------------------------------------------------+
| STYLE INVALIDATION CASCADE PATTERN |
| |
| [ :root Variable Mutation ] |
| └─> Invalidates Entire DOM Tree |
| └─> High Recalculation Cost (Large DOMs) |
| |
| [ Scoped Local Element Mutation ] |
| └─> Invalidates Only Local Component Branch |
| └─> Minimal Engine Cost (60 FPS Safe) |
+-------------------------------------------------------------------------+
- 修改
:root中的属性会迫使浏览器重新计算文档中所有依赖该属性的元素的样式。在页面较大的情况下,每次发生scroll或mousemove事件时都需重新计算,这会消耗大量性能。 - 如果在特定元素(如被悬停的卡片)上设置该属性,则仅需要重新计算该元素的样式即可。
实际操作指南
首先,应将指针位置或滚动进度等高频更新数据发送给使用这些数据的组件,而非 :root。
其次,对于没有后代元素需要的属性值,应使用inherits: false进行注册,这样可以限制动画过程中变化传播的范围:
@property --local-x {
syntax: "<length>";
inherits: false; /* Performance booster! Stops inheritance traversal */
initial-value: 0px;
}
这里有一个重要注意事项:伪元素会从其父元素继承属性,因此设置在元素上但未被继承的注册属性在該元素的::before或::after伪元素中是不可见的。如果伪元素需要读取该属性值,要么保持其继承状态,要么直接在元素本身上应用相应效果。
第三,当某个值用于驱动动画时,应通过transform或opacity来使用它,这样变化就能跳过布局处理,并在可能的情况下避免重绘:
/* Performant GPU layer usage */
.box {
transform: translate3d(var(--x, 0), var(--y, 0), 0);
}
综合应用:主题化仪表板卡片
以下小部件结合了多种技术:两种主题、跟随光标的发光效果、可改变组件颜色的色调控制,以及自适应的标题大小。其标记结构为一个包含标题、切换按钮和范围输入的语义化 <article> 元素:
<article class="dash-card" id="dashCard" data-theme="dark">
<header class="dash-card__header">
<span class="dash-card__badge">Live Metrics</span>
<button class="dash-card__theme-btn" id="themeToggle">Toggle Theme</button>
</header>
<div class="dash-card__body">
<h2 class="dash-card__title">CSS Variable Architecture</h2>
<p class="dash-card__text">
Hover over this component to trigger lighting updates. Use the color slider below to adjust primary hue channels across all child components in real time.
</p>
<div class="dash-card__controls">
<label for="hueSlider">Primary Hue: <span id="hueVal">220</span>°</label>
<input type="range" id="hueSlider" min="0" max="360" value="220">
</div>
</div>
</article>
样式表分为三层结构:已注册的属性和全局令牌、卡片上的主题作用域,以及仅读取这些令牌的组件规则:
/* ==========================================================================
1. DESIGN TOKENS & SYSTEM SCALARS
========================================================================== */
@property --card-glow-x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --card-glow-y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
:root {
/* Default Color Channels */
--hue: 220;
--chroma: 0.18;
/* Derived Dynamic Palette */
--color-primary: oklch(0.60 var(--chroma) var(--hue));
--color-primary-hover: oklch(0.50 var(--chroma) var(--hue));
--color-primary-tint: oklch(0.95 calc(var(--chroma) * 0.3) var(--hue));
/* Layout Dimensions */
--radius-lg: 1rem;
--radius-sm: 0.375rem;
}
/* ==========================================================================
2. THEME SCOPES
========================================================================== */
.dash-card[data-theme="light"] {
--bg-card: #ffffff;
--bg-card-border: #e2e8f0;
--text-main: #0f172a;
--text-muted: #64748b;
--glow-opacity: 0.12;
}
.dash-card[data-theme="dark"] {
--bg-card: #0f172a;
--bg-card-border: #1e293b;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--glow-opacity: 0.25;
}
/* ==========================================================================
3. COMPONENT DECLARATION
========================================================================== */
.dash-card {
position: relative;
width: 100%;
max-width: 480px;
background-color: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
padding: 2rem;
color: var(--text-main);
overflow: hidden;
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
/* Interactive Glow Accent Layer */
.dash-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
600px circle at var(--card-glow-x) var(--card-glow-y),
oklch(0.70 var(--chroma) var(--hue) / var(--glow-opacity)),
transparent 60%
);
pointer-events: none;
transition: opacity 0.3s;
}
/* Header Elements */
.dash-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.dash-card__badge {
padding: 0.25rem 0.75rem;
background-color: var(--color-primary-tint);
color: var(--color-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dash-card__theme-btn {
background: transparent;
border: 1px solid var(--bg-card-border);
color: var(--text-main);
padding: 0.5rem 0.875rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s;
}
.dash-card__theme-btn:hover {
background-color: var(--color-primary-tint);
}
/* Content Elements */
.dash-card__title {
font-size: clamp(1.25rem, 2vw + 1rem, 1.75rem);
margin-bottom: 0.75rem;
color: var(--text-main);
}
.dash-card__text {
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.dash-card__controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: oklch(0.50 var(--chroma) var(--hue) / 0.05);
padding: 1rem;
border-radius: var(--radius-sm);
}
.dash-card__controls label {
font-size: 0.875rem;
font-weight: 600;
}
脚本将各种控件与这些令牌关联起来,始终直接在卡片上写入样式,而非在 :root 上:
/* ==========================================================================
4. REACTIVE SCRIPT BRIDGE
========================================================================== */
const card = document.getElementById('dashCard');
const themeBtn = document.getElementById('themeToggle');
const hueSlider = document.getElementById('hueSlider');
const hueVal = document.getElementById('hueVal');
// 1. Mouse coordinate tracking (Targeted scope prevents full layout invalidation)
card.addEventListener('pointermove', (e) => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--card-glow-x', `${e.clientX - rect.left}px`);
card.style.setProperty('--card-glow-y', `${e.clientY - rect.top}px`);
});
// 2. Interactive Theme Switcher
themeBtn.addEventListener('click', () => {
const currentTheme = card.getAttribute('data-theme');
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
card.setAttribute('data-theme', nextTheme);
});
// 3. Dynamic Color Engine Adjustment
hueSlider.addEventListener('input', (e) => {
const newHue = e.target.value;
hueVal.textContent = newHue;
// Update hue variable locally on component container
card.style.setProperty('--hue', newHue);
});
在发布此类组件之前,需在浏览器中验证两个细节,因为这两点都源于上述机制:
- 光效坐标虽以
inherits: false的方式注册,但会在.dash-card::before中被使用。由于伪元素不会继承这些坐标,它仍然显示初始的0px值,因此光效会停留在角落。若将继承属性设置为inherits: true或不进行注册,光效就会跟随指针移动。 - 滑块会设置卡片的
--hue值,但--color-primary和--color-primary-tint是在:root处计算的,因此徽章会保持其原始颜色。只有直接使用var(--hue)的规则(即光效和控件背景)才会发生变化。在.dash-card上重新声明这些派生颜色值,就能让整个组件随滑块变化。
关键要点
- 将不同主题和状态下会变化的值移到自定义属性中,并为每个组件规则编写一次。
- 在适当的层级声明令牌,无论是
:root、布局区域还是单个组件,然后让继承机制将其传递下去。请记住,派生令牌是在其声明的位置进行计算的。 - 使用
element.style.setProperty()让 JavaScript 直接传递原始值,而非构建样式字符串,并针对需要该值的最小元素进行操作。 - 使用
@property以及<color>、<angle>或<length>等语法注册属性,以实现类型检查与平滑插值。 - 将颜色通道存储在 HSL 或 OKLCH 格式中,从而无需依赖任何库即可生成不同色调、状态和透明度值。
:root 处进行频繁更新,动画效果优先使用 transform 和 opacity,只有在没有后代元素或伪元素需要该值时才使用 inherits: false。自定义属性并非预处理器语法的替代品,而是一种不同的实现方式:这类样式能够感知 DOM 状态、响应用户交互,并可在运行时进行控制。如此使用可让设计系统更简洁且更易于修改。