CSS and Layout skill 8 of 12

CSS Custom Properties SKILL.md: Fix var(), Fallbacks and @property

The CSS Custom Properties skill makes Claude, Cursor or another AI agent find and fix undefined var() names, broken fallbacks, dependency cycles, var() in media queries, ignored @property rules and transitions that snap. A scanner reports each case by file and line, the agent edits only flagged lines, and you approve each batch.

css-custom-properties/SKILL.md, version 1.0.0, 4,015 words, plus scripts/cssvar_check.py and two reference files.

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.

RequestUse instead
Duration, easing, prefers-reduced-motion, transition: allcss-animations-transitions.md
Contrast ratios of the colours inside tokenscolour-contrast.md
Which layer wins when two rules clashcascade-layers.md
clamp() scales for type and spacingfluid-type-spacing.md
Sass variables, Style Dictionary, DTCG token files, Tailwind theme configOut of scope. Name the tool and stop
Choosing the brand palette or the spacing scale itselfOut 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.

InputAsk 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?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as css-custom-properties/SKILL.md.

---
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.

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.

CodeLevelMeaningFix
fallback-not-varerrorFallback begins with a bare --nameStep 2
var-in-at-ruleerrorvar() inside @media, @container, @supports or @importStep 6
var-in-property-nameerrorvar() used where a property name belongsStep 6
property-missing-descriptorerror@property without syntax or without inheritsStep 4
property-no-initialerrorTyped @property without initial-valueStep 4
property-initial-relativeerrorinitial-value in a font-relative unit (em, ex, ch, lh, cap, ic)Step 4
undefined-varwarnvar(--x) with no fallback and no declaration in scope of the scanStep 2
transition-unregisteredwarnA custom property is transitioned and has no valid @propertyStep 4
cyclewarnA name depends on itself through other namesStep 2
name-case-clashwarnTwo names differ only by letter caseStep 5
property-initial-reminforem in initial-valueStep 4
keyframes-sets-custom-propinfoUnregistered property set inside @keyframesStep 7
unused-varinfoDeclared 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.

FactConsequence for the fix
Custom property values are parsed as nearly always valid. A bad value is caught only when var() is substitutedA 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:

IfThen
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 awayTreat as a typo. Rename the use, never the declaration
Neither, and the property is optional by designAdd a fallback whose value the user supplies
Neither, and the property should always existAdd 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.

SituationDecision
A literal appears in 3 or more rules and means the same thingToken
A literal appears once, in one componentLeave 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 breakpointToken, 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.

NeedRegister?Notes
Transition or animate the value between two statesYesAn 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 defaultYesMDN: an invalid value for a registered property falls back to its initial-value
Stop children inheriting a valueYesinherits: false
A plain theme colour read by var()NoRegistration adds a rule and a browser dependency with no gain
syntax: "*" only to set inherits: falseAllowedinitial-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

TierHoldsRead byExample name
PrimitiveRaw values: palette, scale, durationsSemantic tier only--blue-600, --space-4
SemanticPurpose: text, surface, border, focusComponents--text-default, --surface-raised
ComponentOne component's knobsThat 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.

SituationDo
Breakpoint stored as a token and used in @mediaWrite 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 variableNot possible. Use one custom property per property, or a data attribute with a selector
Container query threshold from a tokenSame as media queries
Selector built from a variableNot possible. Use a class or attribute

Step 7: Toggles and animated properties

NeedTechniqueLimits
One switch controls several declarationsSpace 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 4It 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 tweenRegister it (step 4), then transition or animate itOnly the registered types interpolate
var() used inside @keyframesRead from a property set on the element, not from another keyframecsswg-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

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

Failure modes and anti-patterns

MistakeHow to spot itDo instead
A misspelt or missing token with no fallbackundefined-var; a property reverts to black, zero or inheritedRename the use or add the declaration (step 2)
Fallback written as a list or a bare namefallback-not-var; everything after the first comma is swallowedOne fallback, nested var() for a second (step 2)
Expecting an unregistered property to tweentransition-unregistered; it snaps and nothing is loggedRegister it, or animate a native property (step 4)
An @property rule the browser dropsproperty-no-initial, property-initial-relative, property-missing-descriptorComplete the rule or delete it (step 4)
Overriding primitives for dark mode, or components reading primitivesA theme switch leaves some components unchangedOverride 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.

ItemTokensStatusRead condition
A. Description (always loaded)101EstimateEvery session with the skill installed
B. SKILL.md body5,898EstimateWhen the skill fires
C. references/patterns.md1,010EstimateStep 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.md1,154EstimateOnly for support notes, source notes or the rival comparison
Script output (typical run)572EstimateScanner output measured on the worked example (15 lines, 2,001 characters)
D. Typical run (A + B + script output)6,571EstimateCommon path, no references
D. Worst case (A + B + both references + 40 lines of output)9,688EstimateLarge input, every branch. Above 9,000: use head -40 and read one reference at a time
E. Cheaper path6,571EstimateRun 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.

What this skill does

Custom properties fail quietly. A misspelt var(--text-colour) raises no error: MDN says the property takes its inherited value, or its initial value if it does not inherit, so a heading turns black or a gap collapses and the variable takes no blame. A transition on an unregistered custom property snaps, because the browser has no type to interpolate. An @property rule with no initial-value is ignored in full.

The skill runs scripts/cssvar_check.py, which reports file, line and code for 13 patterns, and the agent edits only the flagged lines in batches you approve. It fixes fallbacks, breaks cycles, moves breakpoints out of var(), decides which properties deserve an @property rule and keeps dark-mode overrides in the semantic tier. It asks you for every colour, length and breakpoint it needs instead of choosing one.

Who it is for

Use it if you maintain a token set, review AI-generated CSS, or own a site where a theme switch leaves some components in the old colours. It fits when a var() has no visible effect, when a custom property will not animate, or when an @property rule seems to do nothing. It does not cover Sass or Style Dictionary pipelines, animation timing, contrast ratios or Tailwind configuration.

What is inside

The file is 4,015 words (version 1.0.0) with one script and two reference files. It holds:

  • Six guardrails, including never inventing a fallback or an initial value, and treating a token rename as a bulk edit.
  • Seven inputs the agent must have first, including which values are set outside the scanned files.
  • Eight workflow steps: scan, invalid declarations, what is a token, what to register, tiers, places where var() cannot go, toggles and animation, verify.
  • One tool, scripts/cssvar_check.py, a standard-library scanner with 13 codes that prints file, line and a fixed message but never source text.
  • A worked example with real output, a report template, a starting prompt, five failure modes and a seven-item quality bar.
ItemTokensStatusRead when
Description101EstimateEvery session with the skill installed
SKILL.md body5,898EstimateWhen the skill fires
references/patterns.md1,010EstimateSteps 4, 5, 7 and 8, for the property catalogue, tier and dark-mode blocks, toggle or browser probe
references/appendix.md1,154EstimateOnly for support notes, source notes or the rival comparison
Typical run6,571EstimateDescription, body and scanner output
Worst case9,688EstimateBoth references and 40 lines of output
Cheaper path6,571EstimateRun the scanner, fix flagged lines, skip the references

The figures come from the skill’s own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. The three rivals I read have bodies of about 945 tokens or a body I could not see, and none ships a scanner.

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as css-custom-properties/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the script beside your code. The scanner needs Python 3 and nothing else. Then send the starting prompt from the Templates section.

Before you install. Read the script first. It reads files and writes nothing. The skill tells the agent to edit only on a branch in batches you approve, to take every colour, length and breakpoint from you, and to search for the old name before any token rename. Install skills only from sources you trust.

A worked example

An invented 26-line tokens.css has two names that differ only by case, a two-name cycle, an @property with no initial-value and one with 2em, a var() inside a @media condition, a fallback written as --fallback, and two properties transitioned with no valid rule. The scanner prints 4 errors, 7 warnings and 3 info lines.

The agent asks for the missing values and is given 0deg, 2px, 48em and #222. It keeps one spelling of the brand token, points one side of the cycle at a primitive, nests the fallback, writes the breakpoint as a literal with a comment, completes both @property rules and registers the animated hue. The re-run prints 0 errors, 0 warnings and 0 info. The report lists browser support for @property and any JavaScript that sets tokens as untested.

Common mistakes

The skill is built around five ways this goes wrong:

  • A misspelt or missing token. The property reverts and nothing is logged. The skill renames the use or adds the declaration.
  • A fallback written as a list. Everything after the first comma is the fallback. The skill nests a second var().
  • Expecting an unregistered property to tween. It snaps. The skill registers it or animates a native property.
  • An @property rule the browser drops. A missing initial-value or an em value invalidates it. The skill completes it or deletes it.
  • Overriding primitives for dark mode. Every consumer changes at once. The skill overrides the semantic tier only.

This skill sits in Skills-md, Frontend Development and CSS and Layout. CSS Animations and Transitions covers duration, easing and reduced motion for the properties you register. Cascade Layers decides where the root declarations live. Colour Contrast checks the colours inside your tokens.

Frequently asked questions

Most often the name is misspelt or never declared, so var() has nothing to substitute. The browser then treats the property as invalid at computed-value time and uses its inherited or initial value, according to MDN. You see a reverted property, not an error. The skill flags var() calls with no fallback and no declaration.

Put it after the first comma: var(–brand, #0b6). Everything after the first comma is the fallback, so var(–a, red, blue) gives red, blue. For a second fallback, nest another var(): var(–a, var(–b, pink)). The form var(–a, –b, pink) is invalid, per MDN, and the scanner reports it as an error.

An unregistered custom property has no type, so the browser cannot interpolate it and the value snaps with no console error. Register it with @property and a typed syntax such as or , then transition it. MDN lists @property as Baseline 2024, newly available since July 2024, so check your browser matrix.

Both syntax and inherits, or the whole rule is ignored. The initial-value descriptor is also required unless syntax is *, and it must be computationally independent: 10px is valid and 3em is not, per MDN. The skill’s scanner reports each missing piece and asks you for the value.

No. MDN says var() works in any property value but not in property names, selectors, media queries or container queries. Write the breakpoint as a literal and comment which token it matches. The skill reports var() inside @media, @container, @supports and @import as an error and never suggests unverified tricks.

Use three tiers: primitives hold raw values, semantic tokens name their purpose, and component tokens fall back to a semantic one with a nested var(). Override only the semantic tier for dark mode. These tier rules are the skill’s own house rules, not a standard, and the skill says so.

The description is about 101 tokens and loads in every session. The SKILL.md body is about 5,898 tokens when the skill fires. A typical run is about 6,571 tokens with scanner output, and the worst case with both references is about 9,688. All figures are estimates, not from a model tokeniser.

Changelog

  • 1.0.0, 7 November 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.