Animating CSS Custom Properties with @property — Typed Houdini Variables
Plain CSS variables cannot interpolate. Learn the @property at-rule to register typed custom properties and animate gradient angles, colors, and numbers smoothly.
You can write transition: --my-var 1s all day long and nothing will move. {Bạn có thể viết transition: --my-var 1s cả ngày mà chẳng có gì nhúc nhích.} The browser happily accepts the declaration, then quietly snaps the value at the end instead of interpolating. {Trình duyệt vui vẻ chấp nhận khai báo, rồi lặng lẽ “nhảy cóc” giá trị ở cuối thay vì nội suy.}
The fix is @property, a CSS Houdini at-rule that lets you give a custom property a type. {Cách sửa là @property, một at-rule của CSS Houdini cho phép bạn gán kiểu cho một custom property.} Once a property is typed, the engine knows how to interpolate it — and suddenly gradient angles, color stops, and even raw numbers animate. {Một khi property đã có kiểu, engine biết cách nội suy nó — và đột nhiên góc gradient, color stop, thậm chí số thuần đều animate được.}
If you want the broader story on custom properties, read CSS Custom Properties first. {Nếu bạn muốn bức tranh tổng quát về custom properties, hãy đọc CSS Custom Properties trước.} This post stays laser-focused on animating typed properties. {Bài này tập trung tuyệt đối vào animate các property có kiểu.}
The interactive demo below compares an unregistered custom property (snaps) with a typed @property registration (smooth), then shows gradient angles, an animated border, a progress ring, and an integer counter. {Demo tương tác dưới đây so sánh custom property chưa đăng ký (nhảy cóc) với @property có kiểu (mượt), rồi minh họa góc gradient, border động, progress ring và bộ đếm integer.}
Open the full demo {Mở demo đầy đủ}: /tools/css-at-property-demo/.
Why plain custom properties refuse to animate
A normal custom property is stored as an untyped token string. {Một custom property thường được lưu dưới dạng chuỗi token không kiểu.} The spec calls its grammar <declaration-value> — basically “any sequence of tokens”. {Spec gọi ngữ pháp của nó là <declaration-value> — về cơ bản là “bất kỳ chuỗi token nào”.}
:root {
--angle: 0deg;
}
.box {
--angle: 0deg;
background: conic-gradient(from var(--angle), red, blue);
transition: --angle 1s linear; /* declared… but does nothing */
}
.box:hover {
--angle: 360deg;
}
Because the engine sees --angle as the opaque string "0deg", it has no idea that 0deg and 360deg are two points on a numeric line. {Vì engine coi --angle là chuỗi đục "0deg", nó không hề biết 0deg và 360deg là hai điểm trên một trục số.} With no type, there is no midpoint, so there is no animation — the value just flips at the end. {Không có kiểu thì không có điểm giữa, nên không có animation — giá trị chỉ lật ở cuối.}
Interpolation requires the browser to compute intermediate values. {Nội suy đòi hỏi trình duyệt tính được các giá trị trung gian.} That is only possible when it knows the value is, say, an <angle> and not just text. {Điều đó chỉ khả thi khi nó biết giá trị là, ví dụ, một <angle> chứ không phải chỉ là chữ.}
The @property at-rule
@property registers a custom property with three descriptors. {@property đăng ký một custom property với ba descriptor.}
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
syntax— the type grammar, as a quoted string. {kiểu ngữ pháp, dưới dạng chuỗi trong ngoặc kép.} This is what unlocks interpolation. {Đây chính là thứ mở khóa nội suy.}inherits— whether the value cascades to descendants (true/false). {giá trị có cascade xuống con hay không.} Required, no default. {Bắt buộc, không có mặc định.}initial-value— the fallback used before any author value applies. {giá trị dự phòng dùng trước khi có giá trị nào của tác giả.} Required for every syntax except the universal'*'. {Bắt buộc cho mọi syntax trừ universal'*'.}
All three descriptors are mandatory for a typed property. {Cả ba descriptor đều bắt buộc với một property có kiểu.} Omit initial-value for <angle> and the whole rule is invalid and ignored. {Bỏ initial-value cho <angle> thì cả rule trở nên không hợp lệ và bị bỏ qua.}
Registering from JavaScript
The same registration is available imperatively via CSS.registerProperty. {Cùng việc đăng ký đó cũng có thể làm theo kiểu imperative qua CSS.registerProperty.}
CSS.registerProperty({
name: '--angle',
syntax: '<angle>',
inherits: false,
initialValue: '0deg',
});
Two practical differences. {Hai khác biệt thực tế.} The JS form throws on duplicate registration, so wrap it in a guard if your module can run twice. {Bản JS sẽ throw nếu đăng ký trùng, nên bọc nó trong guard nếu module có thể chạy hai lần.} The CSS form silently no-ops on duplicates, which is usually what you want. {Bản CSS lặng lẽ bỏ qua khi trùng, thường đúng ý bạn.}
// Idempotent guard for the JS form.
try {
CSS.registerProperty({
name: '--angle',
syntax: '<angle>',
inherits: false,
initialValue: '0deg',
});
} catch {
// already registered — fine
}
Prefer the CSS @property form for design-system tokens, and reach for CSS.registerProperty only when the property name or initial value is computed at runtime. {Hãy ưu tiên dạng CSS @property cho token của design-system, và chỉ dùng CSS.registerProperty khi tên property hoặc initial value được tính lúc runtime.}
Which syntaxes become animatable
Typing a property doesn’t just validate input — it tells the engine how to interpolate. {Gán kiểu không chỉ để validate đầu vào — nó nói cho engine biết cách nội suy.} These are the workhorse types. {Đây là các kiểu chủ lực.}
syntax | Example value | Animates as |
|---|---|---|
<number> | 0.5 | numeric scalar |
<integer> | 42 | rounded numeric |
<length> | 16px | numeric with unit |
<percentage> | 50% | numeric percent |
<length-percentage> | 50% / 8px | length or percent |
<color> | #c8ff00 | color space interpolation |
<angle> | 45deg | numeric angle |
<time> | 200ms | numeric time |
You can also accept a fixed list with |, or a space/comma list with + and #. {Bạn cũng có thể nhận một danh sách cố định với |, hoặc danh sách ngăn cách bằng khoảng trắng/dấu phẩy với + và #.}
@property --easing-mode {
syntax: 'smooth | snap'; /* keyword set — NOT interpolable */
inherits: false;
initial-value: smooth;
}
@property --stops {
syntax: '<color>#'; /* a comma-separated list of colors */
inherits: false;
initial-value: red, blue;
}
Keyword sets and the universal '*' syntax validate values but do not interpolate. {Tập keyword và syntax universal '*' thì validate giá trị nhưng không nội suy.} Only the numeric and color-like types above animate smoothly. {Chỉ các kiểu số và màu ở trên mới animate mượt.}
Recipe 1 — animate a conic gradient angle
This is the canonical “you can’t do this without @property” demo. {Đây là demo kinh điển “không có @property thì chịu”.}
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.spinner {
--angle: 0deg;
width: 120px;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(
from var(--angle),
var(--accent, #c8ff00),
transparent 70%
);
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
--angle: 360deg;
}
}
Note we animate the custom property inside @keyframes, not rotate or transform. {Lưu ý ta animate custom property bên trong @keyframes, không phải rotate hay transform.} The gradient repaints each frame as --angle sweeps from 0deg to 360deg. {Gradient được vẽ lại mỗi frame khi --angle quét từ 0deg tới 360deg.}
Recipe 2 — animated gradient border
Combine the angle trick with a masked border layer for a rotating conic border. {Kết hợp mẹo góc với một lớp border được mask để có border conic xoay.}
@property --border-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.card {
position: relative;
border-radius: 12px;
padding: 1.5rem;
background: #0b0b0b;
}
.card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px; /* border thickness */
background: conic-gradient(
from var(--border-angle),
#c8ff00,
#00ffd0,
#c8ff00
);
/* Show only the padding ring, punch out the fill. */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: border-rotate 4s linear infinite;
}
@keyframes border-rotate {
to {
--border-angle: 360deg;
}
}
The mask leaves only the 2px ring painted, so the rotating conic gradient reads as a glowing animated border. {Lớp mask chỉ chừa lại vòng 2px, nên gradient conic xoay trông như một border phát sáng động.}
Recipe 3 — animate a single color stop
Typed <color> and <percentage> properties let you animate one stop without retyping the whole gradient. {Property kiểu <color> và <percentage> cho phép animate một stop mà không phải gõ lại cả gradient.}
@property --fill {
syntax: '<color>';
inherits: false;
initial-value: #1a1a1a;
}
@property --fill-pos {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.bar {
height: 10px;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--fill) var(--fill-pos),
#1a1a1a var(--fill-pos)
);
transition: --fill 300ms ease, --fill-pos 600ms ease;
}
.bar:hover {
--fill: #c8ff00;
--fill-pos: 100%;
}
Because both stops share --fill-pos, you get a clean wipe instead of a soft blur. {Vì cả hai stop dùng chung --fill-pos, bạn có hiệu ứng “lau” gọn thay vì mờ nhòe.}
Recipe 4 — progress ring
A conic gradient driven by a typed <percentage> makes a dependency-free progress ring. {Một conic gradient điều khiển bằng <percentage> có kiểu tạo ra progress ring không cần thư viện.}
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.ring {
--progress: 0%;
width: 96px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(closest-side, #0b0b0b 79%, transparent 80%),
conic-gradient(#c8ff00 var(--progress), #2a2a2a 0);
transition: --progress 700ms cubic-bezier(0.22, 1, 0.36, 1);
}
// Set the target from JS — the transition does the rest.
const ring = document.querySelector('.ring');
ring.style.setProperty('--progress', '72%');
The inner radial-gradient carves out the center, leaving a ring whose fill tracks --progress. {radial-gradient bên trong khoét rỗng phần giữa, để lại một vòng có phần tô bám theo --progress.}
Recipe 5 — animated number counters
Animate a typed <integer>, then surface it as text with counter-reset + content. {Animate một <integer> có kiểu, rồi hiển thị nó thành chữ bằng counter-reset + content.}
@property --count {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.counter {
counter-reset: num var(--count);
animation: count-up 2s forwards ease-out;
}
.counter::after {
content: counter(num);
}
@keyframes count-up {
to {
--count: 1280;
}
}
The <integer> interpolates frame by frame, counter-reset reads the live value, and content: counter(num) renders it — a pure-CSS odometer. {<integer> nội suy từng frame, counter-reset đọc giá trị sống, và content: counter(num) render nó — một đồng hồ đếm thuần CSS.}
This is display-only text and is not selectable, so keep an accessible value in the DOM for important numbers. {Đây là chữ chỉ để hiển thị và không chọn được, nên giữ một giá trị accessible trong DOM cho các con số quan trọng.}
Browser support & fallbacks
@property is supported in all modern evergreen browsers (Chrome/Edge 85+, Safari 16.4+, Firefox 128+). {@property được hỗ trợ trên mọi trình duyệt evergreen hiện đại (Chrome/Edge 85+, Safari 16.4+, Firefox 128+).} For older engines, design the animation as a progressive enhancement. {Với engine cũ, hãy thiết kế animation như một progressive enhancement.}
Feature-detect with @supports. {Phát hiện tính năng bằng @supports.}
/* Static, accessible baseline for everyone. */
.spinner {
background: var(--accent, #c8ff00);
}
@supports (background: conic-gradient(from 1deg, red, blue)) {
/* Enhanced animated version where @property + conic work. */
.spinner {
background: conic-gradient(from var(--angle), var(--accent), transparent 70%);
animation: spin 1.2s linear infinite;
}
}
You can also detect the rule itself in JS. {Bạn cũng có thể phát hiện chính at-rule này trong JS.}
const supportsAtProperty = typeof CSS !== 'undefined' && 'registerProperty' in CSS;
Two more gotchas worth remembering. {Hai điểm gài cần nhớ.} First, always provide initial-value for non-'*' syntaxes or the property silently fails. {Một, luôn cung cấp initial-value cho syntax khác '*' nếu không property sẽ lỗi âm thầm.} Second, respect motion preferences — wrap looping animations in a guard. {Hai, tôn trọng tùy chọn chuyển động — bọc các animation lặp trong một guard.}
@media (prefers-reduced-motion: reduce) {
.spinner,
.card::before,
.counter {
animation: none;
}
}
Wrap-up
- Plain custom properties are untyped strings, so they snap instead of interpolating. {Custom property thường là chuỗi không kiểu, nên chúng nhảy cóc thay vì nội suy.}
@propertyregisters a type viasyntax, plusinheritsandinitial-value. {@propertyđăng ký một kiểu quasyntax, kèminheritsvàinitial-value.}- Numeric and color-like syntaxes animate; keyword sets and
'*'do not. {Các syntax số và màu thì animate; tập keyword và'*'thì không.} - Register in CSS for tokens, in JS (
CSS.registerProperty) for runtime-computed names. {Đăng ký trong CSS cho token, trong JS (CSS.registerProperty) cho tên tính lúc runtime.} - Animate the custom property inside
@keyframesortransition, and let gradients/counters read it. {Animate custom property bên trong@keyframeshoặctransition, rồi để gradient/counter đọc nó.} - Always ship an
@supportsfallback and aprefers-reduced-motionguard. {Luôn kèm fallback@supportsvà guardprefers-reduced-motion.}