CSS Custom Properties & @property — Runtime Theming, Cascade, and Typed Animation
Senior deep dive: CSS custom properties vs Sass, cascade scoping, fallbacks, JS APIs, semantic theming, IACVT, and @property for gradient animation.
Why Custom Properties Matter Now {Tại sao Custom Properties quan trọng bây giờ}
CSS custom properties — often called CSS variables — are not a preprocessor trick {CSS custom properties — thường gọi là CSS variables — không phải mẹo preprocessor}. They are a first-class runtime feature of the cascade {chúng là tính năng runtime first-class của cascade}.
That distinction changes everything for theming {Sự khác biệt đó thay đổi mọi thứ cho theming}: you can update a design token in JavaScript, from a media query, or from a parent scope — and every descendant that references var(--token) recomputes instantly {bạn có thể cập nhật design token trong JavaScript, từ media query, hoặc từ scope parent — và mọi descendant tham chiếu var(--token) tính lại ngay lập tức}.
This post goes deep on how they work, how to architect them, and where @property unlocks animation {Bài viết đi sâu vào cách chúng hoạt động, cách kiến trúc, và nơi @property mở khóa animation} — without repeating general modern-CSS or selector-performance guides {mà không lặp lại hướng dẫn modern-CSS chung hay selector-performance}.
Live Demo {Demo trực tiếp}
Drag sliders to set --brand-hue, --radius, --space, and --font-scale on a live card {Kéo slider để đặt --brand-hue, --radius, --space, và --font-scale trên card trực tiếp}. Toggle the @property-powered conic gradient to see typed interpolation in action {Bật/tắt conic gradient dùng @property để thấy nội suy có kiểu hoạt động}.
Open the full demo {Mở demo đầy đủ}: /tools/css-custom-properties-demo/.
Custom Properties vs Sass Variables {Custom Properties vs Biến Sass}
The most common misconception {Hiểu lầm phổ biến nhất}: “I already use $primary in Sass — why do I need --primary?” {“Tôi đã dùng $primary trong Sass — tại sao cần --primary?”}.
They solve different problems at different times {Chúng giải quyết vấn đề khác nhau ở thời điểm khác nhau}:
| Aspect {Khía cạnh} | Sass $variable | CSS --custom-property |
|---|---|---|
| When resolved {Khi resolve} | Compile time {Thời điểm biên dịch} | Runtime {Runtime} |
| Cascade | No — value is baked into output CSS {Không — giá trị được “nướng” vào CSS output} | Yes — inherits, overrides, scoped by selector {Có — kế thừa, override, scope theo selector} |
| JS readable/writable | No {Không} | Yes — getPropertyValue / setProperty {Có} |
| Media query / user prefs | Must generate duplicate rules {Phải sinh rule trùng lặp} | Native — set on :root or [data-theme] {Native — đặt trên :root hoặc [data-theme]} |
| DOM scoping | Impossible {Không thể} | Set on any element — only descendants see it {Đặt trên bất kỳ element nào — chỉ descendant thấy} |
// Sass — resolved at build; output is a literal color
$brand: #c8ff00;
.button { background: $brand; }
// Compiles to: .button { background: #c8ff00; }
/* CSS custom property — resolved at computed-value time */
:root { --brand: #c8ff00; }
.button { background: var(--brand); }
/* JS can do: document.documentElement.style.setProperty('--brand', '#60a5fa') */
Use both {Dùng cả hai}: Sass for build-time abstractions (mixins, loops, map iteration) {Sass cho abstraction compile-time (mixin, loop, duyệt map)}; custom properties for anything that must react at runtime {custom properties cho mọi thứ phải phản ứng ở runtime} — themes, user settings, component-level overrides, A/B variants {theme, cài đặt người dùng, override cấp component, biến thể A/B}.
The Cascade, Inheritance, and Scoping {Cascade, Kế thừa, và Scoping}
Custom properties follow normal CSS inheritance rules {Custom properties tuân theo quy tắc kế thừa CSS bình thường} — with one important nuance {với một điểm tinh tế quan trọng}.
Declaration and inheritance {Khai báo và kế thừa}
:root {
--space: 1rem;
--brand-hue: 72;
}
.card {
--space: 1.25rem; /* overrides for this subtree only */
padding: var(--space);
}
.card .badge {
/* inherits --space: 1.25rem from .card, not :root */
margin-bottom: var(--space);
}
Unlike regular properties like color, custom properties always inherit unless you set inherits: false via @property {Khác thuộc tính thường như color, custom properties luôn kế thừa trừ khi bạn đặt inherits: false qua @property}.
Scoping patterns {Pattern scoping}
Three common scoping strategies {Ba chiến lược scoping phổ biến}:
/* 1. Global tokens on :root */
:root { --color-accent: #c8ff00; }
/* 2. Theme scope via attribute */
[data-theme="ocean"] { --color-accent: #60a5fa; }
/* 3. Component-local overrides */
.sidebar {
--space-scale: 0.85;
gap: calc(var(--space-base) * var(--space-scale));
}
Key insight {Insight quan trọng}: setting
--xon a parent does not change the stylesheet {đặt--xtrên parent không thay đổi stylesheet}. It injects a cascade layer that descendants resolve against {nó chèn một lớp cascade mà descendant resolve theo đó}. This is why live theming works without rewriting rules {đó là lý do live theming hoạt động mà không cần viết lại rule}.
Fallbacks: var(--x, fallback) {Fallback: var(--x, fallback)}
The var() function accepts a fallback used when the custom property is invalid or undefined at computed-value time {Hàm var() nhận fallback dùng khi custom property invalid hoặc undefined ở computed-value time}:
.button {
/* fallback chain — rightmost valid value wins */
background: var(--btn-bg, var(--brand, #c8ff00));
border-radius: var(--radius, 6px);
}
Fallbacks can themselves contain var() {Fallback có thể chứa var()} — the browser resolves recursively {browser resolve đệ quy}:
.card {
padding: var(--card-padding, var(--space-md, 1rem));
}
When fallbacks fire {Khi nào fallback kích hoạt}
| Situation {Tình huống} | Result {Kết quả} |
|---|---|
| Property never declared {Property chưa khai báo} | Fallback used {Dùng fallback} |
Property set to empty: --x: ; | Fallback used {Dùng fallback} |
| Property set to invalid value for consuming property {Property đặt giá trị invalid cho thuộc tính dùng nó} | See IACVT below {Xem IACVT bên dưới} |
Typo in name: var(--brnad) | Fallback used {Dùng fallback} |
Invalid at Computed-Value Time (IACVT) {Invalid ở Computed-Value Time (IACVT)}
This is the behavior that trips up even experienced developers {Đây là hành vi làm vấp ngay cả dev có kinh nghiệm}.
When a custom property holds a value that is syntactically valid as a custom property but invalid for the property consuming it, the consuming property becomes invalid at computed-value time {Khi custom property giữ giá trị hợp lệ cú pháp là custom property nhưng invalid cho thuộc tính dùng nó, thuộc tính dùng trở thành invalid ở computed-value time} — and the var() fallback is not used {và fallback var() không được dùng}:
:root { --size: 100px; }
.box { width: var(--size, 200px); } /* width: 100px ✓ */
:root { --size: auto; }
.box { width: var(--size, 200px); } /* width is INVALID — not 200px! */
auto is a valid custom property value {auto là giá trị custom property hợp lệ} but invalid for width in this context {nhưng invalid cho width trong ngữ cảnh này}. The entire declaration is dropped {Toàn bộ declaration bị bỏ}, not replaced by the fallback {không thay bằng fallback}.
Practical rule {Quy tắc thực tế}: validate token values at the semantic layer {validate giá trị token ở lớp semantic}. If
--sizemight beauto, split into separate tokens:--width: 100pxvs--width-behavior: auto{Nếu--sizecó thể làauto, tách token:--width: 100pxvs--width-behavior: auto}.
/* Safer pattern — separate concerns */
.resizable {
width: var(--width, 100%);
max-width: var(--max-width, none);
}
Reading and Writing from JavaScript {Đọc và Ghi từ JavaScript}
Custom properties are the CSS ↔ JS bridge for design tokens {Custom properties là cầu nối CSS ↔ JS cho design token}.
Reading {Đọc}
const root = document.documentElement;
const accent = getComputedStyle(root).getPropertyValue('--color-accent').trim();
// Returns " #c8ff00" — note leading space; always .trim()
For scoped reads {Đọc theo scope}:
const card = document.querySelector('.card');
const space = getComputedStyle(card).getPropertyValue('--space').trim();
Writing {Ghi}
// Set on :root — affects entire document
document.documentElement.style.setProperty('--brand-hue', '220');
// Set on a specific element — scoped to its subtree
card.style.setProperty('--space', '1.5rem');
// Remove override — revert to cascade
card.style.removeProperty('--space');
Priority and specificity {Priority và specificity}
Inline style.setProperty writes to the element’s style attribute {Inline style.setProperty ghi vào style attribute của element} — highest author origin priority except !important {priority origin author cao nhất trừ !important}. This is ideal for user-controlled theming sliders {Lý tưởng cho slider theming do user điều khiển} but dangerous if abused for static styles {nhưng nguy hiểm nếu lạm dụng cho style tĩnh}.
// Batch theme switch — one reflow for all token updates
function applyTheme(hue, radius, space) {
const root = document.documentElement.style;
root.setProperty('--brand-hue', String(hue));
root.setProperty('--radius', `${radius}px`);
root.setProperty('--space-scale', String(space));
}
Theming Architecture: Primitives → Semantic → Component {Kiến trúc Theming: Primitives → Semantic → Component}
Production theming scales when you layer tokens instead of hard-coding colors everywhere {Theming production scale khi bạn xếp lớp token thay vì hard-code màu khắp nơi}.
Layer 1: Primitives {Lớp 1: Primitives}
Raw, context-free values {Giá trị thô, không ngữ cảnh}:
:root {
--hue-brand: 72;
--hue-danger: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--radius-sm: 4px;
--radius-md: 8px;
}
Layer 2: Semantic tokens {Lớp 2: Semantic tokens}
Meaningful aliases that describe intent {Alias có nghĩa mô tả ý định}:
:root {
--color-accent: hsl(var(--hue-brand) 85% 55%);
--color-danger: hsl(var(--hue-danger) 70% 55%);
--color-surface: #111111;
--color-text: #e5e5e5;
--color-text-muted: #888888;
--space-inline: var(--space-4);
--radius-control: var(--radius-md);
}
Layer 3: Component tokens {Lớp 3: Component tokens}
Component-specific mappings — optional, only where needed {Mapping theo component — tùy chọn, chỉ khi cần}:
.button--primary {
--btn-bg: var(--color-accent);
--btn-fg: var(--color-bg);
--btn-radius: var(--radius-control);
background: var(--btn-bg);
color: var(--btn-fg);
border-radius: var(--btn-radius);
}
Why three layers? {Tại sao ba lớp?} Changing
--hue-brandrecolors every semantic token derived from it {Đổi--hue-brandđổi màu mọi semantic token derive từ nó}. Changing[data-theme="dark"]only touches semantic layer {Đổi[data-theme="dark"]chỉ chạm lớp semantic}. Components stay stable {Component ổn định}.
Dark mode without duplicate stylesheets {Dark mode không cần stylesheet trùng}
:root,
[data-theme="dark"] {
--color-bg: #0a0a0a;
--color-surface: #111111;
--color-text: #e5e5e5;
}
[data-theme="light"] {
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-text: #1a1a1a;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-text: #1a1a1a;
}
}
One set of component rules {Một bộ rule component}. Tokens swap via cascade {Token đổi qua cascade}. No .dark .button duplication {Không trùng .dark .button}.
Derived Values with calc() and color-mix() {Giá trị derive với calc() và color-mix()}
Custom properties are strings until consumed {Custom properties là chuỗi cho đến khi được dùng} — which means you can compose them {nghĩa là bạn có thể compose chúng}:
:root {
--space-base: 0.25rem;
--space-scale: 1;
--space-md: calc(var(--space-base) * 4 * var(--space-scale));
--brand: hsl(var(--hue-brand) 85% 55%);
--brand-subtle: color-mix(in srgb, var(--brand) 15%, transparent);
}
This is how the demo’s card derives --brand, --brand-dim, and spacing from four slider inputs {Đó là cách card trong demo derive --brand, --brand-dim, và spacing từ bốn input slider} — change one primitive, the entire subtree recalculates {đổi một primitive, toàn bộ subtree tính lại}.
@property — CSS Houdini Typed Custom Properties {@property — Custom Property có kiểu CSS Houdini}
Standard custom properties are untyped strings at the spec level {Custom property chuẩn là chuỗi không kiểu ở mức spec}. The browser cannot interpolate "0deg" to "360deg" during animation because it doesn’t know the value represents an angle {Browser không thể nội suy "0deg" sang "360deg" khi animate vì không biết giá trị là góc}.
@property registers a custom property with an explicit syntax, inheritance behavior, and initial value {@property đăng ký custom property với syntax, hành vi kế thừa, và giá trị khởi tạo rõ ràng}:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --brand-hue {
syntax: "<number>";
inherits: true;
initial-value: 72;
}
Supported syntax values {Giá trị syntax hỗ trợ}
| Syntax | Example initial-value | Use case {Use case} |
|---|---|---|
<angle> | 0deg | Rotating gradients, conic loaders |
<number> | 0 | Counters, progress, hue values |
<length> | 0px | Animated spacing, widths |
<length-percentage> | 0% | Responsive animated sizes |
<color> | #000000 | Color transitions on custom props |
<percentage> | 0% | Opacity-like effects |
Animating a conic gradient {Animate conic gradient}
Without @property {Không có @property}:
/* Snaps — browser treats --angle as untyped string */
.loader {
--angle: 0deg;
background: conic-gradient(from var(--angle), #c8ff00, transparent);
animation: spin 2s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
/* Result: gradient jumps, does not rotate smoothly */
With @property {Có @property}:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.loader {
--angle: 0deg;
background: conic-gradient(from var(--angle), #c8ff00, transparent);
animation: spin 2s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
/* Result: smooth rotation — engine interpolates as angle */
This is one of the few cases where animating a custom property is more performant than JavaScript {Đây là một trong ít trường hợp animate custom property hiệu quả hơn JavaScript} — the animation runs on the compositor thread with no layout or paint {animation chạy trên compositor thread không layout hay paint}.
@propertyalso enables transition on custom properties {@propertycòn bật transition trên custom property}:transition: --progress 0.3s easeworks when--progressis registered as<number>{transition: --progress 0.3s easehoạt động khi--progressđăng ký là<number>}.
For a broader survey of 2026 CSS features including @property browser support {Để khảo sát rộng hơn các tính năng CSS 2026 kèm hỗ trợ trình duyệt @property}, see Modern CSS Features You Should Be Using in 2026.
Performance Notes {Ghi chú Performance}
Custom properties are cheap to read and set {Custom properties rẻ để đọc và đặt} — but expensive when they trigger wide recomputation {nhưng tốn kém khi kích hoạt recompute rộng}.
What triggers work {Cái gì kích hoạt công việc}
Changing --color-accent on :root forces style recalculation for every element that references it (directly or via derived tokens) {Đổi --color-accent trên :root buộc tính lại style cho mọi element tham chiếu nó (trực tiếp hoặc qua token derive)}. Scope changes narrowly when possible {Scope hẹp khi có thể}:
// ❌ Global change — entire tree recalculates
document.documentElement.style.setProperty('--card-padding', '2rem');
// ✅ Scoped — only .sidebar subtree recalculates
sidebar.style.setProperty('--card-padding', '2rem');
Custom properties vs animating layout properties {Custom properties vs animate thuộc tính layout}
Animating a registered <length> custom property used in width still triggers layout {Animate custom property <length> đăng ký dùng trong width vẫn kích hoạt layout} — the typed interpolation helps, but the consuming property determines cost {nội suy có kiểu giúp, nhưng thuộc tính dùng quyết định chi phí}. Prefer animating custom properties consumed by transform, opacity, or gradient-only paint {Ưu tiên animate custom property dùng bởi transform, opacity, hoặc paint chỉ gradient}.
@property registration cost {Chi phí đăng ký @property}
@property rules are parsed once at stylesheet load {Rule @property parse một lần khi load stylesheet}. No per-frame registration cost {Không có chi phí đăng ký mỗi frame}. Safe to register all typed tokens upfront in a tokens.css file {An toàn đăng ký mọi token có kiểu trước trong file tokens.css}.
Avoid custom-property thrashing in JS {Tránh thrashing custom property trong JS}
Same rule as layout thrashing {Cùng quy tắc với layout thrashing} — batch writes, don’t read computed values between writes {batch ghi, không đọc giá trị computed giữa các lần ghi}:
// ❌ Read/write interleave on every frame
function onScroll() {
const y = window.scrollY;
document.documentElement.style.setProperty('--scroll', String(y));
const h = document.documentElement.offsetHeight; // forced layout
}
// ✅ Write only; derive in CSS
function onScroll() {
document.documentElement.style.setProperty('--scroll', String(window.scrollY));
}
Common Patterns Cheat Sheet {Bảng Pattern Phổ biến}
| Pattern {Pattern} | Example |
|---|---|
| Global token | :root { --color-accent: #c8ff00; } |
| Theme switch | [data-theme="x"] { --color-accent: ...; } |
| Component override | .compact { --space-scale: 0.75; } |
| Fallback chain | var(--a, var(--b, 1rem)) |
| JS live update | el.style.setProperty('--x', value) |
| Typed animation | @property --angle { syntax: "<angle>"; ... } |
| Derived token | --brand: hsl(var(--hue) 85% 55%); |
When to Reach for What {Khi nào dùng gì}
| Need {Nhu cầu} | Tool |
|---|---|
| Build-time constants, mixins, loops | Sass / PostCSS variables |
| Runtime theming, user prefs, scoped overrides | CSS custom properties |
| Smooth animation of gradient angles, counters, typed values | @property |
| Static color palette with no runtime change | Either — custom properties still help consistency |
Takeaways {Điểm cần nhớ}
- Custom properties are runtime cascade citizens — they inherit, override, and scope like any CSS value {Custom properties là công dân cascade runtime — kế thừa, override, và scope như mọi giá trị CSS}.
- Sass and CSS variables complement each other — compile-time vs runtime {Sass và CSS variables bổ sung nhau — compile-time vs runtime}.
- Layer primitives → semantic → component tokens for maintainable theming {Xếp lớp primitives → semantic → component token cho theming dễ bảo trì}.
- Watch IACVT — invalid custom property values drop the entire declaration, ignoring fallbacks {Chú ý IACVT — giá trị custom property invalid làm rơi cả declaration, bỏ qua fallback}.
@propertyunlocks typed interpolation — the bridge between custom properties and smooth animation {@propertymở khóa nội suy có kiểu — cầu nối giữa custom property và animation mượt}.- Scope token changes narrowly — global
--*updates on:rootare powerful but wide-reaching {Scope thay đổi token hẹp — cập nhật--*global trên:rootmạnh nhưng ảnh hưởng rộng}.
Custom properties turn CSS from a static stylesheet language into a reactive design system runtime {Custom properties biến CSS từ ngôn ngữ stylesheet tĩnh thành runtime design system phản ứng}. Master the cascade semantics, architect your token layers, and register types where animation demands it {Nắm semantics cascade, kiến trúc lớp token, và đăng ký kiểu khi animation cần} — that is the senior-level mental model {đó là mental model cấp senior}.