---
name: css-custom-properties
description: "Audits and fixes CSS custom properties: undefined or misspelt var() names, fallback mistakes, dependency cycles, var() in media queries, @property rules the browser ignores, transitions that snap, token tiers and dark-mode overrides. Runs a scanner, then edits flagged lines only. Use for 'var() not working', 'CSS variable not animating', '@property', design tokens in CSS or theme switching. Do NOT use for Sass or JS token pipelines, animation timing (use css-animations-transitions), colour contrast or Tailwind config."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/css-layout/css-custom-properties/
  category: frontend-development
  subcategory: css-layout
  version: "1.0.0"
  created: "2026-11-07"
  updated: "2026-11-07"
---

# CSS Custom Properties: Tokens That Fail Loudly Instead of Silently

An agent works on custom properties in this order: confirm scope and browser targets, run the scanner, fix anything that makes a declaration invalid, decide which properties to register with `@property`, put the remaining tokens into tiers, handle the places where `var()` cannot go, and re-run the scanner plus one browser probe. The scanner reads text. It cannot see values set by JavaScript in files outside its scope or by a third-party stylesheet, so it reports patterns for a person or agent to confirm.

Reusable code is in `references/patterns.md`. Browser support notes and the rival comparison are in `references/appendix.md`.

## When to use this skill, and when not to

Use it when a `var()` has no visible effect, a property falls back to black or `initial` for no clear reason, a custom property will not transition or animate, an `@property` rule seems to do nothing, a theme switch leaves some components behind, or tokens need a tier structure.

| Request | Use instead |
|---|---|
| Duration, easing, `prefers-reduced-motion`, `transition: all` | `css-animations-transitions.md` |
| Contrast ratios of the colours inside tokens | `colour-contrast.md` |
| Which layer wins when two rules clash | `cascade-layers.md` |
| `clamp()` scales for type and spacing | `fluid-type-spacing.md` |
| Sass variables, Style Dictionary, DTCG token files, Tailwind theme config | Out of scope. Name the tool and stop |
| Choosing the brand palette or the spacing scale itself | Out of scope. Those values come from the user |

## Guardrails

1. **Propose, then change.** Report first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Values come from the user.** The agent never invents a colour, length or breakpoint for a fallback or an `initial-value`. It asks, or it copies the value the same token already has elsewhere in the code.
3. **Renaming a token is a bulk edit.** Show every file and line that changes before touching any of them. Check that no JavaScript, HTML attribute, test or design-tool export refers to the old name (search for the exact string, including quotes).
4. **Data handled.** The scanner prints file, line, code and a fixed message, never source text. CSS can hold tokens, internal hostnames or customer names inside `url()` values and comments: do not paste them into prompts or reports. Use the token name, not its value, when reporting.
5. **Nothing is invented.** Every rule carries a source and a date. Anything that depends on a browser version carries **verify before relying on this**. Support data in this file is not a substitute for the user's own browser matrix.
6. **Refuses to:** register a property with a guessed `initial-value`, add an `@property` rule to silence the scanner when the property is never animated or type-checked, put a token into a media query by any trick that is not documented here, or declare a fix verified without re-running the scanner.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Scope | "Which files or folders should I review? Give paths." |
| Symptom | "What goes wrong: a value is ignored, a transition snaps, a theme is incomplete, or is this a structure review?" |
| Browsers | "Which browsers and minimum versions must work? Say if older Safari or Firefox ESR is in scope." |
| Other definitions | "Are any custom properties set by JavaScript, a CMS theme, a third-party stylesheet or inline styles outside these paths? List them or say none." |
| Theme model | "How is dark mode switched: `prefers-color-scheme`, a `data-theme` attribute, a class, or not at all?" |
| Names | "Are token names used outside CSS, for example in JavaScript, tests, design files or documentation?" |
| Approval | "May I edit on a branch in batches of one file and show each diff first?" |

## Workflow

### Step 1: Run the scanner

```
python3 scripts/cssvar_check.py src | head -40
```

Read only its output. Do not read the script. The first line holds the counts. `--strict` exits 1 on warnings. It skips `node_modules`, `dist`, `build`, `.git`, `.next` and `vendor`, and reads `.css`, `.scss`, `.sass`, `.less`, `.html`, `.vue`, `.svelte`, `.astro`, `.jsx`, `.tsx`, `.js` and `.ts`.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `fallback-not-var` | error | Fallback begins with a bare `--name` | Step 2 |
| `var-in-at-rule` | error | `var()` inside `@media`, `@container`, `@supports` or `@import` | Step 6 |
| `var-in-property-name` | error | `var()` used where a property name belongs | Step 6 |
| `property-missing-descriptor` | error | `@property` without `syntax` or without `inherits` | Step 4 |
| `property-no-initial` | error | Typed `@property` without `initial-value` | Step 4 |
| `property-initial-relative` | error | `initial-value` in a font-relative unit (`em`, `ex`, `ch`, `lh`, `cap`, `ic`) | Step 4 |
| `undefined-var` | warn | `var(--x)` with no fallback and no declaration in scope of the scan | Step 2 |
| `transition-unregistered` | warn | A custom property is transitioned and has no valid `@property` | Step 4 |
| `cycle` | warn | A name depends on itself through other names | Step 2 |
| `name-case-clash` | warn | Two names differ only by letter case | Step 5 |
| `property-initial-rem` | info | `rem` in `initial-value` | Step 4 |
| `keyframes-sets-custom-prop` | info | Unregistered property set inside `@keyframes` | Step 7 |
| `unused-var` | info | Declared in a stylesheet and never read through `var()` | Step 5 |

The scanner splits on text, not on a parsed stylesheet. A hit inside a string, template literal or comment-like text is a false positive: read the line. `cycle` ignores scope, so two properties that depend on each other on different elements are reported too: confirm they share one element. An `@property` rule that the scanner rejects is not counted as registered, so a transition on it is also reported.

### Step 2: Fix declarations that are invalid at computed-value time

Sources: MDN, Using CSS custom properties, fetched 7 November 2026; W3C, CSS Custom Properties for Cascading Variables Module Level 1, Candidate Recommendation Snapshot of 16 June 2022, fetched 7 November 2026.

| Fact | Consequence for the fix |
|---|---|
| Custom property values are parsed as nearly always valid. A bad value is caught only when `var()` is substituted | A typo or a wrong type shows up as a property reverting, not as a parse error |
| If the substituted value is invalid, the property takes its inherited value when it inherits, otherwise its initial value (MDN) | `color: var(--text-colour)` with a missing token gives the inherited colour, and `padding` gives its initial value. Look for the reverted property, not the variable |
| Everything after the first comma is the fallback: `var(--foo, red, blue)` gives `red, blue` (MDN) | One fallback per `var()`. A fallback that itself needs a fallback is a nested `var()` |
| `var(--a, --b, pink)` is invalid (MDN) | Write `var(--a, var(--b, pink))` |
| `var(--a,)` is valid and gives an empty fallback (W3C) | Use it only on purpose, for the toggle in step 7 |
| A cycle makes every property in it invalid at computed-value time (W3C) | Break the cycle at the semantic tier: one side must point at a primitive |
| Names are case-sensitive: `--My-color` is not `--my-color` (MDN) | All token names lower-case kebab-case |

Decision rules for `undefined-var`:

| If | Then |
|---|---|
| The user listed it as defined elsewhere (input "Other definitions") | Leave it. List it in the report as unverified |
| A declared name is one character edit away | Treat as a typo. Rename the use, never the declaration |
| Neither, and the property is optional by design | Add a fallback whose value the user supplies |
| Neither, and the property should always exist | Add the declaration in the tier where it belongs (step 5), value from the user |

Fix `fallback-not-var` and `cycle` in the same batch as `undefined-var`, because all three are one edit to the same `var()` or declaration.

### Step 3: Decide what is a token

House rules of this skill, not facts from a source. Change them only if the user's design system says otherwise.

| Situation | Decision |
|---|---|
| A literal appears in 3 or more rules and means the same thing | Token |
| A literal appears once, in one component | Leave it as a literal |
| Two uses share a value by coincidence (a 4px gap and a 4px radius) | Two tokens, or no token. Do not merge by value |
| A value changes with theme, density or breakpoint | Token, in the semantic tier |
| A value is set by JavaScript at runtime (a pointer position, a scroll ratio) | Custom property on the element, unregistered unless it must animate |

### Step 4: Decide what to register with `@property`

Source: MDN, `@property`, fetched 7 November 2026.

| Need | Register? | Notes |
|---|---|---|
| Transition or animate the value between two states | Yes | An unregistered property has no type, so the browser cannot interpolate it and the value snaps, with no console error (dev.to, Parsa Jiravand, edited 11 August, year not stated) |
| An invalid value should fall back to a known default instead of the using property's own default | Yes | MDN: an invalid value for a registered property falls back to its `initial-value` |
| Stop children inheriting a value | Yes | `inherits: false` |
| A plain theme colour read by `var()` | No | Registration adds a rule and a browser dependency with no gain |
| `syntax: "*"` only to set `inherits: false` | Allowed | `initial-value` is optional for `*` only |

Rules for a valid rule, all from MDN:

1. `syntax` and `inherits` must both be present or the whole rule is ignored.
2. `initial-value` is required for every syntax except `*`. Without it the rule is invalid and ignored.
3. `initial-value` must be computationally independent: `10px` and `2in` are valid, `3em` is not. MDN's example is `em`. The scanner applies the same rule to the other font-relative units, which is an inference: **verify before relying on this** for `rem`, which the scanner reports as info only.
4. An invalid rule is ignored. If several valid rules share a name, the last in stylesheet order wins.
5. `CSS.registerProperty()` with the same name overrides the `@property` rule.

Choosing `inherits`: `false` when only the element itself reads the value (an animated angle on one component), `true` when a parent sets it and descendants read it. The `initial-value` comes from the user (guardrail 2). A registered property that is never transitioned, animated or type-checked fails the table above: remove the rule instead of fixing it.

Support: MDN lists `@property` as Baseline 2024, newly available, with support across the latest browsers since July 2024. **Verify before relying on this** if the user's browser matrix includes anything older. A browser that ignores the rule still applies the variable, but transitions snap (dev.to, same article). Do not add `@supports` guards unless the animation carries information (the same article's advice; the skill treats it as a judgement call for the user).

### Step 5: Put tokens into tiers

| Tier | Holds | Read by | Example name |
|---|---|---|---|
| Primitive | Raw values: palette, scale, durations | Semantic tier only | `--blue-600`, `--space-4` |
| Semantic | Purpose: text, surface, border, focus | Components | `--text-default`, `--surface-raised` |
| Component | One component's knobs | That component's own rules | `--card-pad`, `--btn-radius` |

Rules:

1. Components read semantic tokens. A component that reads a primitive directly is reported, not rewritten, unless the user approves.
2. A component token always has a fallback that points one tier up: `padding: var(--card-pad, var(--space-4))`. The nested form is the one MDN documents.
3. Dark mode, density and brand overrides replace semantic values only. Overriding primitives breaks every consumer at once. The override block pattern is in `references/patterns.md`, section 2.
4. Names are lower-case kebab-case, prefixed by category (`--color-`, `--space-`, `--font-`, `--z-`). Resolve every `name-case-clash` by keeping one spelling and updating every use.
5. `unused-var` is information, not an instruction to delete. A token can be read by JavaScript or by another repository. Ask before removing any.
6. Put the root declarations in one file and one cascade layer if the project uses layers (`cascade-layers.md`).

### Step 6: Handle places where `var()` cannot go

Source: MDN, Using CSS custom properties, fetched 7 November 2026: `var()` works in any property value, and not in property names, selectors, media queries or container queries.

| Situation | Do |
|---|---|
| Breakpoint stored as a token and used in `@media` | Write the literal in the query and a comment naming the token. Ask the user whether a build step should keep the two in sync. Do not suggest `env()` or any other trick: no source read for this skill confirms it |
| Property name built from a variable | Not possible. Use one custom property per property, or a data attribute with a selector |
| Container query threshold from a token | Same as media queries |
| Selector built from a variable | Not possible. Use a class or attribute |

### Step 7: Toggles and animated properties

| Need | Technique | Limits |
|---|---|---|
| One switch controls several declarations | Space toggle: set the switch to `initial` so every `var(--switch, value)` applies its fallback, or to a whitespace-only value so every read is empty (Lea Verou, 12 October 2020). Code in `references/patterns.md`, section 4 | It can append values or set a whole value. It cannot say "red if set, otherwise white". Name the constants and comment them because `--off: ;` looks like a mistake |
| A value must tween | Register it (step 4), then transition or animate it | Only the registered types interpolate |
| `var()` used inside `@keyframes` | Read from a property set on the element, not from another keyframe | csswg-drafts issue 411: Firefox substitutes before interpolation and Chrome after, and the resolved behaviour was never written into the Animations spec (Tab Atkins Jr., 30 July 2021). Firefox bug 1763376 records custom properties in keyframes being lost, closed as a duplicate, last updated 25 August 2025. **Verify before relying on this** in each target browser |

The empty value `--foo: ;` is valid and is not the guaranteed-invalid value, which is the initial value of every custom property (W3C, 16 June 2022).

For duration, easing and reduced-motion handling of whatever you register, hand over to `css-animations-transitions.md`. This skill stops at making the property animatable.

### Step 8: Verify and report

1. Re-run the scanner. Errors must be 0. Every warning is fixed or listed with a reason.
2. Run the browser probe in `references/patterns.md`, section 5, on one element per fixed finding. It prints the computed value of each token. An empty string for a token that should have a value means the declaration is not reaching that element.
3. If any `@property` rule was added or changed, load the page in each browser the user named and check that the transition runs. Report browsers not tested.
4. State what is untested: values set by JavaScript, third-party stylesheets, browsers outside the matrix.
5. Use the report template. Do not echo unchanged CSS back.

## Worked example

An invented `tokens.css` of 26 lines: two names that differ by case, a two-name cycle, an `@property` with no `initial-value`, one with `2em`, a `var()` in a `@media` condition, a fallback written as `--fallback`, and two properties that are transitioned without a valid rule.

```
$ python3 scripts/cssvar_check.py tokens.css
4 errors, 7 warnings, 3 info, 1 files
tokens.css:2 warn  name-case-clash  names differ only by case (--Brand, --brand) ...
tokens.css:5 warn  cycle  dependency cycle --a -> --b -> --a ...
tokens.css:9 error property-no-initial  initial-value is required unless syntax is "*" ...
tokens.css:10 error property-initial-relative  initial-value must be computationally independent ...
tokens.css:12 error var-in-at-rule  var() does not work in media or container query conditions ...
tokens.css:12 warn  undefined-var  --bp-md is used with no fallback and declared nowhere ...
tokens.css:16 error fallback-not-var  fallback starts with a bare --name: invalid ...
tokens.css:18 warn  transition-unregistered  --angle is transitioned but has no @property ...
tokens.css:25 warn  transition-unregistered  --hue is transitioned but has no @property ...
```

(Messages shortened, 5 lines omitted.) The agent asks for the missing values. The user gives `0deg` for the angle, `2px` for the size, `48em` as the breakpoint and `#222` for the text colour. The agent keeps `--brand`, deletes `--Brand` and rewrites its one use, makes `--b` point at a primitive, writes `var(--edge, #ccc)`, replaces the media query condition with `48em` and a comment, adds `initial-value: 0deg` and `2px`, and registers `--hue` as a `<number>` with `inherits: false` and the value `220` the user supplied.

```
$ python3 scripts/cssvar_check.py tokens.css
0 errors, 0 warnings, 0 info, 1 files
```

The scanner passing shows that the patterns it knows are gone. It does not show that `@property` is supported in the user's oldest browser or that no JavaScript reads `--Brand`. Both go in the report as untested.

## Templates

### Report

```
Custom property report: <repo or folder>, <date>
Files: <count>. Browsers: <matrix>. Theme model: <prefers-color-scheme | data-theme | class | none>.
cssvar_check before: N errors, N warnings, N info. After: N errors, N warnings, N info.
Changes: <file:line  code  old -> new>   (one line each)
Values supplied by the user: <token  value>
Registered with @property (reason): <name  syntax  inherits>
Left as is: <file:line  reason>
Untested: values set by JavaScript, third-party CSS, browsers outside the matrix (edit to match)
```

### Starting prompt

```
Use the css-custom-properties skill. Scope: <paths>. Browsers: <matrix>.
Symptom: <value ignored | transition snaps | theme incomplete | structure review>.
Defined elsewhere: <list or none>. Theme switch: <model>. Names used outside CSS: <where or none>.
Edit on a branch in batches of one file and show each diff first.
```

### Registered, animatable property

```css
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg; /* value supplied by the user */
}
.dial { transition: --angle 300ms; }
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| A misspelt or missing token with no fallback | `undefined-var`; a property reverts to black, zero or inherited | Rename the use or add the declaration (step 2) |
| Fallback written as a list or a bare name | `fallback-not-var`; everything after the first comma is swallowed | One fallback, nested `var()` for a second (step 2) |
| Expecting an unregistered property to tween | `transition-unregistered`; it snaps and nothing is logged | Register it, or animate a native property (step 4) |
| An `@property` rule the browser drops | `property-no-initial`, `property-initial-relative`, `property-missing-descriptor` | Complete the rule or delete it (step 4) |
| Overriding primitives for dark mode, or components reading primitives | A theme switch leaves some components unchanged | Override the semantic tier only (step 5) |

## Quality bar

Run every item before handing over.

1. `cssvar_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. Every value added (fallback, `initial-value`, breakpoint literal) came from the user, and the report lists each one.
3. No component reads a primitive directly unless the report says the user approved it.
4. Every registered property is transitioned, animated or type-checked somewhere, and the report names where.
5. No token name changed without a search for the old name in JavaScript, HTML, tests and documentation.
6. The browser probe ran on one element per fixed finding, or the report says why not.
7. Browsers outside the user's matrix are listed as untested.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 101 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 5,898 | Estimate | When the skill fires |
| C. `references/patterns.md` | 1,010 | Estimate | Step 5 for the tier and dark-mode blocks, step 4 for the property catalogue, step 7 for the toggle, step 8 for the browser probe |
| C. `references/appendix.md` | 1,154 | Estimate | Only for support notes, source notes or the rival comparison |
| Script output (typical run) | 572 | Estimate | Scanner output measured on the worked example (15 lines, 2,001 characters) |
| D. Typical run (A + B + script output) | 6,571 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 9,688 | Estimate | Large input, every branch. Above 9,000: use `head -40` and read one reference at a time |
| E. Cheaper path | 6,571 | Estimate | Run the scanner, fix only flagged lines, skip both references and the browser probe |

The three rivals I read have bodies of about 945 tokens or a body that was not visible (estimates from visible text). None ships a scanner and none covers `@property` rules the browser ignores. This skill costs more to load, and in return the scanner finds the patterns so the agent does not read every stylesheet to look for them.

## Handoffs

`css-animations-transitions.md` covers timing, easing, reduced motion and entry and exit animation, and starts where step 7 stops. `colour-contrast.md` checks the colours inside the tokens. `cascade-layers.md` decides where the root declarations live. All four read different sources, so none repeats another.

## Appendix

Reusable code is in `references/patterns.md`. Support notes and the rival comparison are in `references/appendix.md`. Read them only when a step points there.

## Sources

Last verified 7 November 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- MDN, `@property`: https://developer.mozilla.org/en-US/docs/Web/CSS/@property
- MDN, Using CSS custom properties: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascading_variables/Using_custom_properties
- W3C, CSS Custom Properties for Cascading Variables Module Level 1 (Candidate Recommendation Snapshot, 16 June 2022): https://www.w3.org/TR/css-variables-1/
- W3C public-css-archive, csswg-drafts issue 411, comment of 30 July 2021: https://lists.w3.org/Archives/Public/public-css-archive/2021Jul/0553.html
- Mozilla Bugzilla, bug 1763376, custom properties introduced by @keyframes have no effect: https://bugzilla.mozilla.org/show_bug.cgi?id=1763376
- Lea Verou, The --var: ; hack to toggle multiple values with one custom property (12 October 2020): https://lea.verou.me/2020/10/the-var-space-hack-to-toggle-multiple-values-with-one-custom-property/
- Parsa Jiravand, You can't transition a CSS variable? Property says otherwise (dev.to): https://dev.to/parsajiravand/you-cant-transition-a-css-variable-property-says-otherwise-50a0
- laurigates, design-tokens. Rival implementation: https://claudeskills.info/skills/laurigates/claude-plugins/design-tokens/
- glebis, design-tokens. Rival implementation: https://claudeskills.info/skills/glebis/claude-skills/design-tokens/
- design-system-patterns. Rival implementation, listing only: https://tacoskill.duckdns.org/skill/design-system-patterns--1d7c8ab4
- Anthropic, frontend-design. Related implementation: https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/css-layout/css-custom-properties/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
