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.

Tại sao Custom Properties quan trọng bây giờ

CSS custom properties — thường gọi là CSS variables — không phải mẹo preprocessor. chúng là tính năng runtime first-class của cascade.

Sự khác biệt đó thay đổi mọi thứ cho theming: 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.

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 — mà không lặp lại hướng dẫn modern-CSS chung hay selector-performance.


Demo trực tiếp

Kéo slider để đặt --brand-hue, --radius, --space, và --font-scale trên card trực tiếp. Bật/tắt conic gradient dùng @property để thấy nội suy có kiểu hoạt động.

Mở demo đầy đủ:


Custom Properties vs Biến Sass

Hiểu lầm phổ biến nhất: ““Tôi đã dùng $primary trong Sass — tại sao cần --primary?”.

Chúng giải quyết vấn đề khác nhau ở thời điểm khác nhau:

Khía cạnhSass $variableCSS --custom-property
Khi resolveThời điểm biên dịchRuntime
CascadeKhông — giá trị được “nướng” vào CSS outputCó — kế thừa, override, scope theo selector
JS readable/writableKhông
Media query / user prefsPhải sinh rule trùng lặpNative — đặt trên :root hoặc [data-theme]
DOM scopingKhông thểĐặ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') */

Dùng cả hai: Sass cho abstraction compile-time (mixin, loop, duyệt map); custom properties cho mọi thứ phải phản ứng ở runtime — theme, cài đặt người dùng, override cấp component, biến thể A/B.


Cascade, Kế thừa, và Scoping

Custom properties tuân theo quy tắc kế thừa CSS bình thường — với một điểm tinh tế quan trọng.

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);
}

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.

Pattern scoping

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));
}

Insight quan trọng: đặt --x trên parent không thay đổi stylesheet. nó chèn một lớp cascade mà descendant resolve theo đó. đó là lý do live theming hoạt động mà không cần viết lại rule.


Fallback: var(--x, fallback)

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);
}

Fallback có thể chứa var() — browser resolve đệ quy:

.card {
  padding: var(--card-padding, var(--space-md, 1rem));
}

Khi nào fallback kích hoạt

Tình huốngKết quả
Property chưa khai báoDùng fallback
Property set to empty: --x: ;Dùng fallback
Property đặt giá trị invalid cho thuộc tính dùng nóXem IACVT bên dưới
Typo in name: var(--brnad)Dùng fallback

Invalid ở Computed-Value Time (IACVT)

Đây là hành vi làm vấp ngay cả dev có kinh nghiệm.

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 — 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 là giá trị custom property hợp lệ nhưng invalid cho width trong ngữ cảnh này. Toàn bộ declaration bị bỏ, không thay bằng fallback.

Quy tắc thực tế: validate giá trị token ở lớp semantic. 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);
}

Đọc và Ghi từ JavaScript

Custom properties là cầu nối CSS ↔ JS cho design token.

Đọc

const root = document.documentElement;
const accent = getComputedStyle(root).getPropertyValue('--color-accent').trim();
// Returns " #c8ff00" — note leading space; always .trim()

Đọc theo scope:

const card = document.querySelector('.card');
const space = getComputedStyle(card).getPropertyValue('--space').trim();

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 và specificity

Inline style.setProperty ghi vào style attribute của element — priority origin author cao nhất trừ !important. Lý tưởng cho slider theming do user điều khiển 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));
}

Kiến trúc Theming: Primitives → Semantic → Component

Theming production scale khi bạn xếp lớp token thay vì hard-code màu khắp nơi.

Lớp 1: Primitives

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;
}

Lớp 2: Semantic tokens

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);
}

Lớp 3: Component tokens

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);
}

Tại sao ba lớp? Đổi --hue-brand đổi màu mọi semantic token derive từ nó. Đổi [data-theme="dark"] chỉ chạm lớp semantic. Component ổn định.

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;
  }
}

Một bộ rule component. Token đổi qua cascade. Không trùng .dark .button.


Giá trị derive với calc()color-mix()

Custom properties là chuỗi cho đến khi được dùng — 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);
}

Đó là cách card trong demo derive --brand, --brand-dim, và spacing từ bốn input slider — đổi một primitive, toàn bộ subtree tính lại.


@property — Custom Property có kiểu CSS Houdini

Custom property chuẩn là chuỗi không kiểu ở mức spec. Browser không thể nội suy "0deg" sang "360deg" khi animate vì không biết giá trị là góc.

@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;
}

Giá trị syntax hỗ trợ

SyntaxExample initial-valueUse 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

Animate conic gradient

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 */

@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 */

Đây là một trong ít trường hợp animate custom property hiệu quả hơn JavaScript — animation chạy trên compositor thread không layout hay paint.

@property còn bật transition trên custom property: transition: --progress 0.3s ease hoạt động khi --progress đăng ký là <number>.

Để 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,


Ghi chú Performance

Custom properties rẻ để đọcđặt — nhưng tốn kém khi kích hoạt recompute rộng.

Cái gì kích hoạt công việc

Đổ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 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 animate thuộc tính layout

Animate custom property <length> đăng ký dùng trong width vẫn kích hoạt layout — nội suy có kiểu giúp, nhưng thuộc tính dùng quyết định chi phí. Ưu tiên animate custom property dùng bởi transform, opacity, hoặc paint chỉ gradient.

Chi phí đăng ký @property

Rule @property parse một lần khi load stylesheet. Không có chi phí đăng ký mỗi frame. An toàn đăng ký mọi token có kiểu trước trong file tokens.css.

Tránh thrashing custom property trong JS

Cùng quy tắc với layout thrashing — 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));
}

Bảng Pattern Phổ biến

PatternExample
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%);

Khi nào dùng gì

Nhu cầuTool
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

Điểm cần nhớ

  1. Custom properties là công dân cascade runtime — kế thừa, override, và scope như mọi giá trị CSS.
  2. Sass và CSS variables bổ sung nhau — compile-time vs runtime.
  3. Xếp lớp primitives → semantic → component token cho theming dễ bảo trì.
  4. Chú ý IACVT — giá trị custom property invalid làm rơi cả declaration, bỏ qua fallback.
  5. @property mở khóa nội suy có kiểu — cầu nối giữa custom property và animation mượt.
  6. 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 biến CSS từ ngôn ngữ stylesheet tĩnh thành runtime design system phản ứng. Nắm semantics cascade, kiến trúc lớp token, và đăng ký kiểu khi animation cần — đó là mental model cấp senior.