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.
Bạn có thể viết transition: --my-var 1s cả ngày mà chẳng có gì nhúc nhích. 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.
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. 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.
Nếu bạn muốn bức tranh tổng quát về custom properties, hãy đọc CSS Custom Properties trước. Bài này tập trung tuyệt đối vào animate các property có kiểu.
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.
Mở demo đầy đủ:
Why plain custom properties refuse to animate
Một custom property thường được lưu dưới dạng chuỗi token không kiểu. 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;
}
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ố. 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.
Nội suy đòi hỏi trình duyệt tính được các giá trị trung gian. Đ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 đăng ký một custom property với ba descriptor.
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
- kiểu ngữ pháp, dưới dạng chuỗi trong ngoặc kép. Đây chính là thứ mở khóa nội suy.
- giá trị có cascade xuống con hay không. Bắt buộc, không có mặc định.
- giá trị dự phòng dùng trước khi có giá trị nào của tác giả. Bắt buộc cho mọi syntax trừ universal
'*'.
Cả ba descriptor đều bắt buộc với một property có kiểu. Bỏ initial-value cho <angle> thì cả rule trở nên không hợp lệ và bị bỏ qua.
Registering from JavaScript
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',
});
Hai khác biệt thực tế. 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. 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
}
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
Gán kiểu không chỉ để validate đầu vào — nó nói cho engine biết cách nội suy. Đâ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 |
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;
}
Tập keyword và syntax universal '*' thì validate giá trị nhưng không nội suy. Chỉ các kiểu số và màu ở trên mới animate mượt.
Recipe 1 — animate a conic gradient angle
Đâ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;
}
}
Lưu ý ta animate custom property bên trong @keyframes, không phải rotate hay transform. Gradient được vẽ lại mỗi frame khi --angle quét từ 0deg tới 360deg.
Recipe 2 — animated gradient 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;
}
}
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
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%;
}
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
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%');
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 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;
}
}
<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.
Đâ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 được hỗ trợ trên mọi trình duyệt evergreen hiện đại (Chrome/Edge 85+, Safari 16.4+, Firefox 128+). Với engine cũ, hãy thiết kế animation như một progressive enhancement.
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;
}
}
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;
Hai điểm gài cần nhớ. Một, luôn cung cấp initial-value cho syntax khác '*' nếu không property sẽ lỗi âm thầm. 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
- Custom property thường là chuỗi không kiểu, nên chúng nhảy cóc thay vì nội suy.
@propertyđăng ký một kiểu quasyntax, kèminheritsvàinitial-value.- Các syntax số và màu thì animate; tập keyword và
'*'thì không. - Đăng ký trong CSS cho token, trong JS (
CSS.registerProperty) cho tên tính lúc runtime. - Animate custom property bên trong
@keyframeshoặctransition, rồi để gradient/counter đọc nó. - Luôn kèm fallback
@supportsvà guardprefers-reduced-motion.