jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 0deg360deg 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.}

syntaxExample valueAnimates as
<number>0.5numeric scalar
<integer>42rounded numeric
<length>16pxnumeric with unit
<percentage>50%numeric percent
<length-percentage>50% / 8pxlength or percent
<color>#c8ff00color space interpolation
<angle>45degnumeric angle
<time>200msnumeric 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 +#.}

@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><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.}
  • @property registers a type via syntax, plus inherits and initial-value. {@property đăng ký một kiểu qua syntax, kèm inheritsinitial-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 @keyframes or transition, and let gradients/counters read it. {Animate custom property bên trong @keyframes hoặc transition, rồi để gradient/counter đọc nó.}
  • Always ship an @supports fallback and a prefers-reduced-motion guard. {Luôn kèm fallback @supports và guard prefers-reduced-motion.}