Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default defineConfig([
| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes |
| [`no-invalid-named-grid-areas`](./docs/rules/no-invalid-named-grid-areas.md) | Disallow invalid named grid areas | yes |
| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes |
| [`no-unknown-animations`](./docs/rules/no-unknown-animations.md) | Disallow unknown animation names | no |
| [`no-unmatchable-selectors`](./docs/rules/no-unmatchable-selectors.md) | Disallow unmatchable selectors | yes |
| [`prefer-logical-properties`](./docs/rules/prefer-logical-properties.md) | Enforce the use of logical properties | no |
| [`relative-font-units`](./docs/rules/relative-font-units.md) | Enforce the use of relative font units | no |
Expand Down
99 changes: 99 additions & 0 deletions docs/rules/no-unknown-animations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# no-unknown-animations

Disallow unknown animation names.

## Background

CSS animations are created by assigning a [`@keyframes`](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes) rule's name to the [`animation-name`](https://developer.mozilla.org/en-US-US/docs/Web/CSS/animation-name) property or the [`animation`](https://developer.mozilla.org/en-US/docs/Web/CSS/animation) shorthand property, as in this example:

```css
.card {
animation: fade-in 300ms ease;
}

@keyframes fade-in {
from {
opacity: 0;
}

to {
opacity: 1;
}
}
```

If an animation name doesn't match any `@keyframes` rule, for example because of a typo or because the `@keyframes` rule was renamed or removed, the animation silently fails to run without any error.

## Rule Details

This rule warns when an animation name used in `animation` or `animation-name` doesn't match any `@keyframes` rule defined in the same source.

Animation names are case-sensitive, and quoted and unquoted names refer to the same animation, so `animation-name: "fade-in"` matches `@keyframes fade-in`.

The rule only checks statically determinable animation names. Dynamic animation names, such as those using `var()`, are ignored.

Examples of **incorrect** code for this rule:

```css
/* eslint css/no-unknown-animations: "error" */

.card {
animation: fade-in 300ms ease;
}

.button {
animation-name: slide-up;
}

@keyframes fade-out {
from {
opacity: 1;
}

to {
opacity: 0;
}
}
```

Examples of **correct** code for this rule:

```css
/* eslint css/no-unknown-animations: "error" */

.card {
animation: fade-in 300ms ease;
}

.button {
animation-name: slide-up;
}

@keyframes fade-in {
from {
opacity: 0;
}

to {
opacity: 1;
}
}

@keyframes slide-up {
from {
transform: translateY(8px);
}

to {
transform: translateY(0);
}
}
```

## When Not to Use It

Animations can reference `@keyframes` rules defined in another stylesheet, but this rule only checks `@keyframes` rules defined in the same source. If your `@keyframes` rules are defined separately from where the animations are used, you should not use this rule.

## Prior Art

- [`no-unknown-animations`](https://stylelint.io/user-guide/rules/no-unknown-animations/)
141 changes: 141 additions & 0 deletions src/rules/no-unknown-animations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @fileoverview Rule to disallow unknown animation names.
* @author Gaic4o
*/

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/**
* @import { CSSRuleDefinition } from "../types.js"
* @import { CssLocationRange } from "@eslint/css-tree"
* @typedef {"unknownAnimation"} NoUnknownAnimationsMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoUnknownAnimationsMessageIds }>} NoUnknownAnimationsRuleDefinition
*/

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

const animationPropertyPattern = /^animation(?:-name)?$/iu;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also check for vendored prefixes (e.g. -webkit-animation) as the @keyframes check also does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve addressed the issue you pointed out. Thank you!


/**
* Extracts an animation name from a node. Quoted and unquoted animation
* names refer to the same animation, so `"fade-in"` and `fade-in` both
* yield `fade-in`.
* @param {Object} node The node to extract the animation name from.
* @returns {string|null} The animation name, or `null` if the node isn't a name.
*/
function getAnimationName(node) {
if (node.type === "Identifier") {
return node.name;
}

if (node.type === "String") {
return node.value;
}

return null;
}

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------

export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({
meta: {
type: "problem",

docs: {
description: "Disallow unknown animation names",
recommended: false,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-unknown-animations.md",
},

messages: {
unknownAnimation: "Unknown animation name '{{name}}' found.",
},
},

create(context) {
const lexer = context.sourceCode.lexer;

/** @type {Set<string>} */
const definedAnimations = new Set();

/** @type {Array<{ name: string, loc: CssLocationRange }>} */
const usedAnimations = [];

return {
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i] > AtrulePrelude"(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i] > AtrulePrelude"(
"Atrule[name=/^(-(o|ms|moz|webkit)-)?keyframes$/i] > AtrulePrelude"(

The "ms" vendor-prefix is also missing here. Please also add a test case for this.

node,
) {
const child = node.children[0];
const name = child ? getAnimationName(child) : null;

if (name !== null) {
definedAnimations.add(name);
}
},

"Rule > Block Declaration"(node) {
if (
!animationPropertyPattern.test(node.property) ||
node.value.type !== "Value"
) {
return;
}

const matchResult = lexer.matchProperty(
node.property,
node.value,
);

/*
* If the value can't be matched against the property grammar,
* its animation name can't be determined reliably. This
* includes dynamic values such as var(). Invalid property

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the rule should support checking local resolvable var declarations.
There will be a helper for this but this rule could already check the default value of a var, e.g. "slide-in" in animation: var(--animation-name, "slide-in").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. After looking into it, I think it makes more sense for this rule to check values that can be determined statically, rather than trying to fully resolve every var() usage.

For example, var(--animation-name) would still be ignored when its actual value cannot be determined, while cases such as var(--animation-name, "slide-in") could be checked by extracting the statically known animation name from the fallback value.

For resolving local custom property values themselves, I think it would be better not to implement that separately in this PR, and instead make use of the helper you mentioned once it is available. So for this PR, I’m planning to support checking statically resolvable fallback values first.

@Gaic4o Gaic4o Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up implementing var() handling a little more broadly than I initially described. Even when a value contains var(), the rule now checks fallback values as well as any statically known animation names around it. Actual custom property value resolution is still something I plan to handle later using the helper you mentioned.

One thing I’d like your opinion on is that this implementation re-parses the value using parse() from @eslint/css-tree. Since this does not use the custom parser when customSyntax is configured, I’d like to know whether you think this approach is okay.

* values are outside the scope of this rule.
*/
if (matchResult.error) {
return;
}

for (const child of node.value.children) {
if (!matchResult.isType(child, "keyframes-name")) {
continue;
}

const name = getAnimationName(child);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name should not be null as the lexer already checks that it is a string or an identifier. Otherwise a test case for this is missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the null check on the usage side. As you pointed out, the lexer only matches an identifier or a string as <keyframes-name>, so it can't be null in this case.

I kept the check on the @keyframes prelude side, though. This part isn't validated by the lexer, so @keyframes 50% can be parsed as Percentage and @keyframes 1s as Dimension. I also added tests for both cases.


if (name !== null) {
usedAnimations.push({
name,
loc: child.loc,
});
}
}
},

/*
* Usages are reported only after the entire stylesheet has been
* visited so that `@keyframes` rules defined after their usage
* are still found.
*/
"StyleSheet:exit"() {
for (const { name, loc } of usedAnimations) {
if (definedAnimations.has(name)) {
continue;
}

context.report({
loc,
messageId: "unknownAnimation",
data: { name },
});
}
},
};
},
});
Loading