jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 $variableCSS --custom-property
When resolved {Khi resolve}Compile time {Thời điểm biên dịch}Runtime {Runtime}
CascadeNo — 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/writableNo {Không}Yes — getPropertyValue / setProperty {Có}
Media query / user prefsMust 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 scopingImpossible {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 --x on a parent does not change the stylesheet {đặt --x trê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 --size might be auto, split into separate tokens: --width: 100px vs --width-behavior: auto {Nếu --size có thể là auto, tách token: --width: 100px vs --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-brand recolors 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()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ợ}

SyntaxExample initial-valueUse case {Use case}
<angle>0degRotating gradients, conic loaders
<number>0Counters, progress, hue values
<length>0pxAnimated spacing, widths
<length-percentage>0%Responsive animated sizes
<color>#000000Color 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}.

@property also enables transition on custom properties {@property còn bật transition trên custom property}: transition: --progress 0.3s ease works when --progress is registered as <number> {transition: --progress 0.3s ease hoạ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đặ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 chainvar(--a, var(--b, 1rem))
JS live updateel.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, loopsSass / PostCSS variables
Runtime theming, user prefs, scoped overridesCSS custom properties
Smooth animation of gradient angles, counters, typed values@property
Static color palette with no runtime changeEither — custom properties still help consistency

Takeaways {Điểm cần nhớ}

  1. 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}.
  2. Sass and CSS variables complement each other — compile-time vs runtime {Sass và CSS variables bổ sung nhau — compile-time vs runtime}.
  3. Layer primitives → semantic → component tokens for maintainable theming {Xếp lớp primitives → semantic → component token cho theming dễ bảo trì}.
  4. 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}.
  5. @property unlocks typed interpolation — the bridge between custom properties and smooth animation {@property mở khóa nội suy có kiểu — cầu nối giữa custom property và animation mượt}.
  6. Scope token changes narrowly — global --* updates on :root are powerful but wide-reaching {Scope thay đổi token hẹp — cập nhật --* global trên :root mạ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}.