Repository: eslint/css
Pull request #143 (2025-05-21 19:04:04)

feat: add new rule `no-duplicate-keyframe-selectors`
<!--
    Thank you for contributing!

    ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/latest/contribute/pull-requests)
    - Update or create tests
    - If performance-related, include a benchmark
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What is the purpose of this pull request?



Add new rule `no-duplicate-keyframe-selectors`.


#### What changes did you make? (Give an overview)

Examples of **incorrect** code for this rule:

```css
/* eslint css/no-duplicate-keyframe-selectors: "error" */

@keyframes test {
	0% {
		opacity: 0;
	}

	0% {
		opacity: 1;
	}
}

@keyframes test {
	from {
		opacity: 0;
	}

	from {
		opacity: 1;
	}
}

@keyframes test {
	from {
		opacity: 0;
	}

	from {
		opacity: 1;
	}
}
```

Examples of **correct** code for this rule:

```css
/* eslint css/no-duplicate-keyframe-selectors: "error" */

@keyframes test {
	0% {
		opacity: 0;
	}

	100% {
		opacity: 1;
	}
}

@keyframes test {
	from {
		opacity: 0;
	}

	to {
		opacity: 1;
	}
}
```


#### Related Issues

Fix https://github.com/eslint/css/issues/141
<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

<!-- markdownlint-disable-file MD004 -->


nzakas: @snitin315 can you check the merge conflicts?
snitin315: Yes

Patch:
diff --git a/.gitignore b/.gitignore
index 609570f..76b1207 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,6 @@ pnpm-lock.yaml
 
 # Build
 dist/
+
 src/build
+test.css
diff --git a/README.md b/README.md
index 043620f..155a2d4 100644
--- a/README.md
+++ b/README.md
@@ -65,19 +65,20 @@ export default defineConfig([
 
 <!-- Rule Table Start -->
 
-| **Rule Name**                                                                  | **Description**                        | **Recommended** |
-| :----------------------------------------------------------------------------- | :------------------------------------- | :-------------: |
-| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md)                 | Disallow duplicate @import rules       |       yes       |
-| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)                           | Disallow empty blocks                  |       yes       |
-| [`no-important`](./docs/rules/no-important.md)                                 | Disallow !important flags              |       yes       |
-| [`no-invalid-at-rule-placement`](./docs/rules/no-invalid-at-rule-placement.md) | Disallow invalid placement of at-rules |       yes       |
-| [`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       |
-| [`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        |
-| [`use-baseline`](./docs/rules/use-baseline.md)                                 | Enforce the use of baseline features   |       yes       |
-| [`use-layers`](./docs/rules/use-layers.md)                                     | Require use of layers                  |       no        |
+| **Rule Name**                                                                        | **Description**                                     | **Recommended** |
+| :----------------------------------------------------------------------------------- | :-------------------------------------------------- | :-------------: |
+| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md)                       | Disallow duplicate @import rules                    |       yes       |
+| [`no-duplicate-keyframe-selectors`](./docs/rules/no-duplicate-keyframe-selectors.md) | Disallow duplicate selectors within keyframe blocks |       yes       |
+| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)                                 | Disallow empty blocks                               |       yes       |
+| [`no-important`](./docs/rules/no-important.md)                                       | Disallow !important flags                           |       yes       |
+| [`no-invalid-at-rule-placement`](./docs/rules/no-invalid-at-rule-placement.md)       | Disallow invalid placement of at-rules              |       yes       |
+| [`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       |
+| [`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        |
+| [`use-baseline`](./docs/rules/use-baseline.md)                                       | Enforce the use of baseline features                |       yes       |
+| [`use-layers`](./docs/rules/use-layers.md)                                           | Require use of layers                               |       no        |
 
 <!-- Rule Table End -->
 
@@ -285,8 +286,7 @@ Apache 2.0
 The following companies, organizations, and individuals support ESLint's ongoing maintenance and development. [Become a Sponsor](https://eslint.org/donate)
 to get your logo on our READMEs and [website](https://eslint.org/sponsors).
 
-<h3>Diamond Sponsors</h3>
-<p><a href="https://www.ag-grid.com/"><img src="https://images.opencollective.com/ag-grid/bec0580/logo.png" alt="AG Grid" height="128"></a></p><h3>Platinum Sponsors</h3>
+<h3>Platinum Sponsors</h3>
 <p><a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="128"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="128"></a></p><h3>Gold Sponsors</h3>
 <p><a href="https://qlty.sh/"><img src="https://images.opencollective.com/qltysh/33d157d/logo.png" alt="Qlty Software" height="96"></a> <a href="https://trunk.io/"><img src="https://images.opencollective.com/trunkio/fb92d60/avatar.png" alt="trunk.io" height="96"></a> <a href="https://shopify.engineering/"><img src="https://avatars.githubusercontent.com/u/8085" alt="Shopify" height="96"></a></p><h3>Silver Sponsors</h3>
 <p><a href="https://vite.dev/"><img src="https://images.opencollective.com/vite/e6d15e1/logo.png" alt="Vite" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301" alt="American Express" height="64"></a> <a href="https://stackblitz.com"><img src="https://avatars.githubusercontent.com/u/28635252" alt="StackBlitz" height="64"></a></p><h3>Bronze Sponsors</h3>
diff --git a/docs/rules/no-duplicate-keyframe-selectors.md b/docs/rules/no-duplicate-keyframe-selectors.md
new file mode 100644
index 0000000..bfe3f14
--- /dev/null
+++ b/docs/rules/no-duplicate-keyframe-selectors.md
@@ -0,0 +1,95 @@
+# no-duplicate-keyframe-selectors
+
+Disallow duplicate selectors within keyframe blocks.
+
+## Background
+
+The [`@keyframes` at-rule](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes) in CSS defines intermediate steps in an animation sequence. Each keyframe selector (like `0%`, `50%`, `100%`, `from`, or `to`) represents a point in the animation timeline and contains styles to apply at that point.
+
+```css
+@keyframes test {
+	0% {
+		opacity: 0;
+	}
+
+	100% {
+		opacity: 1;
+	}
+}
+```
+
+If a selector is repeated within the same @keyframes block, the last declaration wins, potentially causing unintentional overrides or confusion.
+
+## Rule Details
+
+This rule warns when it finds a keyframe block that contains duplicate selectors.
+
+Examples of **incorrect** code for this rule:
+
+```css
+/* eslint css/no-duplicate-keyframe-selectors: "error" */
+
+@keyframes test {
+	0% {
+		opacity: 0;
+	}
+
+	0% {
+		opacity: 1;
+	}
+}
+
+@keyframes test {
+	from {
+		opacity: 0;
+	}
+
+	from {
+		opacity: 1;
+	}
+}
+
+@keyframes test {
+	from {
+		opacity: 0;
+	}
+
+	from {
+		opacity: 1;
+	}
+}
+```
+
+Examples of **correct** code for this rule:
+
+```css
+/* eslint css/no-duplicate-keyframe-selectors: "error" */
+
+@keyframes test {
+	0% {
+		opacity: 0;
+	}
+
+	100% {
+		opacity: 1;
+	}
+}
+
+@keyframes test {
+	from {
+		opacity: 0;
+	}
+
+	to {
+		opacity: 1;
+	}
+}
+```
+
+## When Not to Use It
+
+If you aren't concerned with duplicate selectors within keyframe blocks, you can safely disable this rule.
+
+## Prior Art
+
+- [`keyframe-block-no-duplicate-selectors`](https://stylelint.io/user-guide/rules/keyframe-block-no-duplicate-selectors/)
diff --git a/eslint.config.js b/eslint.config.js
index 74d6622..c9d9e58 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -11,6 +11,7 @@ import eslintConfigESLint from "eslint-config-eslint";
 import eslintPlugin from "eslint-plugin-eslint-plugin";
 import json from "@eslint/json";
 import { defineConfig, globalIgnores } from "eslint/config";
+import css from "./src/index.js";
 
 //-----------------------------------------------------------------------------
 // Helpers
@@ -30,7 +31,7 @@ const eslintPluginTestsRecommendedConfig =
 //-----------------------------------------------------------------------------
 
 export default defineConfig([
-	globalIgnores(["**/tests/fixtures/", "**/dist/"]),
+	globalIgnores(["**/tests/fixtures/", "**/dist/", "test.css"]),
 
 	...eslintConfigESLint.map(config => ({
 		files: ["**/*.js"],
@@ -115,6 +116,12 @@ export default defineConfig([
 			"eslint-plugin/test-case-shorthand-strings": "error",
 		},
 	},
+	{
+		files: ["**/*.css"],
+		language: "css/css",
+		plugins: { css },
+		extends: ["css/recommended"],
+	},
 	{
 		files: ["tools/**/*.js"],
 		rules: {
diff --git a/package.json b/package.json
index 32a6061..c6efe03 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,7 @@
   ],
   "license": "Apache-2.0",
   "dependencies": {
-    "@eslint/core": "^0.14.0",
+    "@eslint/core": "^0.15.0",
     "@eslint/css-tree": "^3.6.1",
     "@eslint/plugin-kit": "^0.3.1"
   },
diff --git a/src/rules/no-duplicate-keyframe-selectors.js b/src/rules/no-duplicate-keyframe-selectors.js
new file mode 100644
index 0000000..da97d74
--- /dev/null
+++ b/src/rules/no-duplicate-keyframe-selectors.js
@@ -0,0 +1,81 @@
+/**
+ * @fileoverview Rule to disallow duplicate selectors within keyframe blocks.
+ * @author Nitin Kumar
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/**
+ * @import { CSSRuleDefinition } from "../types.js"
+ * @typedef {"duplicateKeyframeSelector"} DuplicateKeyframeSelectorMessageIds
+ * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: DuplicateKeyframeSelectorMessageIds }>} DuplicateKeyframeSelectorRuleDefinition
+ */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+/** @type {DuplicateKeyframeSelectorRuleDefinition} */
+export default {
+	meta: {
+		type: "problem",
+
+		docs: {
+			description: "Disallow duplicate selectors within keyframe blocks",
+			recommended: true,
+			url: "https://github.com/eslint/css/blob/main/docs/rules/no-duplicate-keyframe-selectors.md",
+		},
+
+		messages: {
+			duplicateKeyframeSelector:
+				"Unexpected duplicate selector '{{selector}}' found within keyframe block.",
+		},
+	},
+
+	create(context) {
+		let insideKeyframes = false;
+		const seen = new Map();
+
+		return {
+			"Atrule[name=keyframes]"() {
+				insideKeyframes = true;
+				seen.clear();
+			},
+
+			"Atrule[name=keyframes]:exit"() {
+				insideKeyframes = false;
+			},
+
+			Rule(node) {
+				if (!insideKeyframes) {
+					return;
+				}
+
+				// @ts-ignore - children is a valid property for prelude
+				const selector = node.prelude.children[0].children[0];
+				let value;
+				if (selector.type === "Percentage") {
+					value = `${selector.value}%`;
+				} else if (selector.type === "TypeSelector") {
+					value = selector.name.toLowerCase();
+				} else {
+					value = selector.value;
+				}
+
+				if (seen.has(value)) {
+					context.report({
+						loc: selector.loc,
+						messageId: "duplicateKeyframeSelector",
+						data: {
+							selector: value,
+						},
+					});
+				} else {
+					seen.set(value, true);
+				}
+			},
+		};
+	},
+};
diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js
index 271b0b8..104d236 100644
--- a/src/rules/no-invalid-properties.js
+++ b/src/rules/no-invalid-properties.js
@@ -26,36 +26,44 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js";
 //-----------------------------------------------------------------------------
 
 /**
- * Replaces all instances of a regex pattern with a replacement and tracks the offsets
- * @param {string} text The text to perform replacements on
- * @param {string} varName The regex pattern string to search for
- * @param {string} replaceValue The string to replace with
- * @returns {{text: string, offsets: Array<number>}} The updated text and array of offsets
- * where replacements occurred
+ * Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function.
+ * For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`.
+ * @param {string} value The fallback value that is used in `var()`.
+ * @return {Array<string>} The list of variable names of fallback value.
  */
-function replaceWithOffsets(text, varName, replaceValue) {
-	const offsets = [];
-	let result = "";
-	let lastIndex = 0;
+function getVarFallbackList(value) {
+	const list = [];
+	let currentValue = value;
 
-	const regex = new RegExp(`var\\(\\s*${varName}\\s*\\)`, "gu");
-	let match;
+	while (true) {
+		const match = currentValue.match(
+			/var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/u,
+		);
 
-	while ((match = regex.exec(text)) !== null) {
-		result += text.slice(lastIndex, match.index);
+		if (!match) {
+			break;
+		}
 
-		/*
-		 * We need the offset of the replacement after other replacements have
-		 * been made, so we push the current length of the result before appending
-		 * the replacement value.
-		 */
-		offsets.push(result.length);
-		result += replaceValue;
-		lastIndex = match.index + match[0].length;
+		const prop = match[1].trim();
+		const fallback = match[2]?.trim();
+
+		list.push(prop);
+
+		if (!fallback) {
+			break;
+		}
+
+		// If fallback is not another var(), we're done
+		if (!fallback.includes("var(")) {
+			list.push(fallback);
+			break;
+		}
+
+		// Continue parsing from fallback
+		currentValue = fallback;
 	}
 
-	result += text.slice(lastIndex);
-	return { text: result, offsets };
+	return list;
 }
 
 //-----------------------------------------------------------------------------
@@ -146,46 +154,137 @@ export default {
 
 				const varsFound = replacements.pop();
 
-				/** @type {Map<number,CssLocationRange>} */
-				const varsFoundLocs = new Map();
+				/** @type {Map<string,CssLocationRange>} */
+				const valuesWithVarLocs = new Map();
 				const usingVars = varsFound?.size > 0;
 				let value = node.value;
 
 				if (usingVars) {
-					// need to use a text version of the value here
-					value = sourceCode.getText(node.value);
-					let offsets;
-
-					// replace any custom properties with their values
-					for (const [name, func] of varsFound) {
-						const varValue = vars.get(name);
-
-						if (varValue) {
-							({ text: value, offsets } = replaceWithOffsets(
-								value,
-								name,
-								sourceCode.getText(varValue).trim(),
-							));
-
-							/*
-							 * Store the offsets of the replacements so we can
-							 * report the correct location of any validation error.
-							 */
-							offsets.forEach(offset => {
-								varsFoundLocs.set(offset, func.loc);
-							});
-						} else if (!allowUnknownVariables) {
-							context.report({
-								loc: func.children[0].loc,
-								messageId: "unknownVar",
-								data: {
-									var: name,
-								},
-							});
-
-							return;
+					const valueList = [];
+					const valueNodes = node.value.children;
+
+					// When `var()` is used, we store all the values to `valueList` with the replacement of `var()` with there values or fallback values
+					for (const child of valueNodes) {
+						// If value is a function starts with `var()`
+						if (child.type === "Function" && child.name === "var") {
+							const varValue = vars.get(child.children[0].name);
+
+							// If the variable is found, use its value, otherwise check for fallback values
+							if (varValue) {
+								const varValueText = sourceCode
+									.getText(varValue)
+									.trim();
+
+								valueList.push(varValueText);
+								valuesWithVarLocs.set(varValueText, child.loc);
+							} else {
+								// If the variable is not found and doesn't have a fallback value, report it
+								if (child.children.length === 1) {
+									if (!allowUnknownVariables) {
+										context.report({
+											loc: child.children[0].loc,
+											messageId: "unknownVar",
+											data: {
+												var: child.children[0].name,
+											},
+										});
+
+										return;
+									}
+								} else {
+									// If it has a fallback value, use that
+									if (child.children[2].type === "Raw") {
+										const fallbackVarList =
+											getVarFallbackList(
+												child.children[2].value.trim(),
+											);
+										if (fallbackVarList.length > 0) {
+											let gotFallbackVarValue = false;
+
+											for (const fallbackVar of fallbackVarList) {
+												if (
+													fallbackVar.startsWith("--")
+												) {
+													const fallbackVarValue =
+														vars.get(fallbackVar);
+
+													if (!fallbackVarValue) {
+														continue; // Try the next fallback
+													}
+
+													valueList.push(
+														sourceCode
+															.getText(
+																fallbackVarValue,
+															)
+															.trim(),
+													);
+													valuesWithVarLocs.set(
+														sourceCode
+															.getText(
+																fallbackVarValue,
+															)
+															.trim(),
+														child.loc,
+													);
+													gotFallbackVarValue = true;
+													break; // Stop after finding the first valid variable
+												} else {
+													const fallbackValue =
+														fallbackVar.trim();
+													valueList.push(
+														fallbackValue,
+													);
+													valuesWithVarLocs.set(
+														fallbackValue,
+														child.loc,
+													);
+													gotFallbackVarValue = true;
+													break; // Stop after finding the first non-variable fallback
+												}
+											}
+
+											// If none of the fallback value is defined then report an error
+											if (
+												!allowUnknownVariables &&
+												!gotFallbackVarValue
+											) {
+												context.report({
+													loc: child.children[0].loc,
+													messageId: "unknownVar",
+													data: {
+														var: child.children[0]
+															.name,
+													},
+												});
+
+												return;
+											}
+										} else {
+											// if it has a fallback value, use that
+											const fallbackValue =
+												child.children[2].value.trim();
+											valueList.push(fallbackValue);
+											valuesWithVarLocs.set(
+												fallbackValue,
+												child.loc,
+											);
+										}
+									}
+								}
+							}
+						} else {
+							// If the child is not a `var()` function, just add its text to the `valueList`
+							const valueText = sourceCode.getText(child).trim();
+							valueList.push(valueText);
+							valuesWithVarLocs.set(valueText, child.loc);
 						}
 					}
+
+					value =
+						valueList.length > 0
+							? valueList.join(" ")
+							: sourceCode.getText(node.value);
 				}
 
 				const { error } = lexer.matchProperty(node.property, value);
@@ -193,6 +292,13 @@ export default {
 				if (error) {
 					// validation failure
 					if (isSyntaxMatchError(error)) {
+						const errorValue =
+							usingVars &&
+							value.slice(
+								error.mismatchOffset,
+								error.mismatchOffset + error.mismatchLength,
+							);
+
 						context.report({
 							/*
 							 * When using variables, check to see if the error
@@ -201,7 +307,7 @@ export default {
 							 * reported location.
 							 */
 							loc: usingVars
-								? (varsFoundLocs.get(error.mismatchOffset) ??
+								? (valuesWithVarLocs.get(errorValue) ??
 									node.value.loc)
 								: error.loc,
 							messageId: "invalidPropertyValue",
diff --git a/src/types.ts b/src/types.ts
index 4a66056..fbf8bd0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,7 +7,11 @@
 // Imports
 //------------------------------------------------------------------------------
 
-import type { RuleVisitor, RuleDefinition } from "@eslint/core";
+import type {
+	CustomRuleDefinitionType,
+	CustomRuleTypeDefinitions,
+	RuleVisitor,
+} from "@eslint/core";
 
 import type { CssNodePlain, CssNodeNames } from "@eslint/css-tree";
 
@@ -46,28 +50,19 @@ export interface CSSRuleVisitor
 	extends RuleVisitor,
 		Partial<WithExit<CSSNodeVisitor>> {}
 
-export type CSSRuleDefinitionTypeOptions = {
-	RuleOptions: unknown[];
-	MessageIds: string;
-	ExtRuleDocs: Record<string, unknown>;
-};
+export type CSSRuleDefinitionTypeOptions = CustomRuleTypeDefinitions;
 
 /**
  * A rule definition for CSS.
  */
 export type CSSRuleDefinition<
 	Options extends Partial<CSSRuleDefinitionTypeOptions> = {},
-> = RuleDefinition<
-	// Language specific type options (non-configurable)
+> = CustomRuleDefinitionType<
 	{
 		LangOptions: CSSLanguageOptions;
 		Code: CSSSourceCode;
 		Visitor: CSSRuleVisitor;
 		Node: CssNodePlain;
-	} & Required<
-		// Rule specific type options (custom)
-		Options &
-			// Rule specific type options (defaults)
-			Omit<CSSRuleDefinitionTypeOptions, keyof Options>
-	>
+	},
+	Options
 >;


Test patch:
diff --git a/tests/rules/no-duplicate-keyframe-selectors.test.js b/tests/rules/no-duplicate-keyframe-selectors.test.js
new file mode 100644
index 0000000..504aafa
--- /dev/null
+++ b/tests/rules/no-duplicate-keyframe-selectors.test.js
@@ -0,0 +1,249 @@
+/**
+ * @fileoverview Tests for no-duplicate-keyframe-selectors rule.
+ * @author Nitin Kumar
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-duplicate-keyframe-selectors.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+import dedent from "dedent";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("no-duplicate-keyframe-selectors", rule, {
+	valid: [
+		dedent`@keyframes test {
+            from { opacity: 0; }
+            to { opacity: 1; }
+        }`,
+		dedent`@keyframes test {
+            0% { opacity: 0; }
+            100% { opacity: 1; }
+        }`,
+		dedent`@keyframes test {
+            0% { opacity: 0; }
+            50% { opacity: 0.5; }
+            100% { opacity: 1; }
+        }`,
+		dedent`@keyframes test {
+            from { opacity: 0; }
+            50% { opacity: 0.5; }
+            to { opacity: 1; }
+        }`,
+		dedent`@keyframes test {
+        }`,
+		dedent`@keyframes test {
+            0% { opacity: 0; }
+        }`,
+		dedent`@keyframes test {
+            0% { opacity: 0; }
+            0.0% { opacity: 1; }
+        }`,
+	],
+	invalid: [
+		{
+			code: dedent`@keyframes test {
+                0% { opacity: 0; }
+                0% { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 3,
+					column: 5,
+					endLine: 3,
+					endColumn: 7,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                0% { opacity: 0; }
+                10.5% { opacity: 0.15; }
+                10.5% { opacity: 0.25; }
+                100% { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 4,
+					column: 5,
+					endLine: 4,
+					endColumn: 10,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                from { opacity: 0; }
+                from { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 3,
+					column: 5,
+					endLine: 3,
+					endColumn: 9,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                from { opacity: 0; }
+                From { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 3,
+					column: 5,
+					endLine: 3,
+					endColumn: 9,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                from { opacity: 0; }
+                FROM { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 3,
+					column: 5,
+					endLine: 3,
+					endColumn: 9,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                from { opacity: 0; }
+                to { opacity: 1; }
+                to { opacity: 2; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 4,
+					column: 5,
+					endLine: 4,
+					endColumn: 7,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                from { opacity: 0; }
+                to { opacity: 1; }
+                TO { opacity: 2; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 4,
+					column: 5,
+					endLine: 4,
+					endColumn: 7,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                0% { opacity: 0; }
+                50% { opacity: 0.5; }
+                50% { opacity: 0.75; }
+                100% { opacity: 1; }
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 4,
+					column: 5,
+					endLine: 4,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                0% {
+                    opacity: 0;
+                }
+
+                0% {
+                    opacity: 1;
+                }
+
+                50% {
+                    opacity: 0.5;
+                }
+
+                50% {
+                    opacity: 0.75;
+                }
+
+                50% {
+                    opacity: 0.5;
+                }
+
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 6,
+					column: 5,
+					endLine: 6,
+					endColumn: 7,
+				},
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 14,
+					column: 5,
+					endLine: 14,
+					endColumn: 8,
+				},
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 18,
+					column: 5,
+					endLine: 18,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: dedent`@keyframes test {
+                /* Start */
+                0% { opacity: 0; }
+                /* Middle */
+                0% { opacity: 1; }
+                /* End */
+            }`,
+			errors: [
+				{
+					messageId: "duplicateKeyframeSelector",
+					line: 5,
+					column: 5,
+					endLine: 5,
+					endColumn: 7,
+				},
+			],
+		},
+	],
+});
diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js
index b35627a..d876874 100644
--- a/tests/rules/no-invalid-properties.test.js
+++ b/tests/rules/no-invalid-properties.test.js
@@ -37,6 +37,24 @@ ruleTester.run("no-invalid-properties", rule, {
 		":root { --my-color: red; }\na { color: var(   --my-color   ) }",
 		":root { --my-color: red;\n.foo { color: var(--my-color) }\n}",
 		".fluidHeading {font-size: clamp(2.1rem, calc(7.2vw - 0.2rem), 2.5rem);}",
+		"a { color: var(--my-color, red) }",
+		":root { --my-heading: 3rem; }\na { color: var(--my-color, red) }",
+		":root { --my-heading: 3rem; --foo: red }\na { color: var(--my-color, var(--foo, blue)) }",
+		":root { --my-heading: 3rem; }\na { color: var(--my-color, var(--foo, blue)) }",
+		"a { color: var(--my-color, var(--foo, var(--bar, blue))) }",
+		":root { --my-color: red; }\na { color: var(--my-color, blue) }",
+		":root { --my-fallback: red; }\na { color: var(--my-color, var(--my-fallback)) }",
+		":root { --my-fallback: red; }\na { color: var(--my-color, var(--my-fallback, blue)) }",
+		":root { --foo: red; }\na { color: var(--my-color, var(--my-fallback, var(--foo))) }",
+		"a { color: var(--my-color, var(--my-fallback, var(--foo, blue))) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color)) }",
+		":root { --my-color: red; --fallback-color: blue; }\na { color: var(--my-color, var(--fallback-color)) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, blue)) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo))) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, blue))) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar)))) }",
+		":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar, blue)))) }",
+		":root { --color: red }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color, blue); }",
 		{
 			code: "a { my-custom-color: red; }",
 			languageOptions: {
@@ -55,6 +73,10 @@ ruleTester.run("no-invalid-properties", rule, {
 			code: "a { --my-color: red; color: var(--my-color); background-color: var(--unknown-var); }",
 			options: [{ allowUnknownVariables: true }],
 		},
+		{
+			code: ":root { --color: red }\na { border-top: 1px var(--style, var(--fallback)) var(--color, blue); }",
+			options: [{ allowUnknownVariables: true }],
+		},
 
 		/*
 		 * CSSTree doesn't currently support custom functions properly, so leaving
@@ -372,7 +394,7 @@ ruleTester.run("no-invalid-properties", rule, {
 						expected: "<line-width> || <line-style> || <color>",
 					},
 					line: 1,
-					column: 31,
+					column: 50,
 					endLine: 1,
 					endColumn: 53,
 				},
@@ -456,5 +478,119 @@ ruleTester.run("no-invalid-properties", rule, {
 				},
 			],
 		},
+		{
+			code: "a { border-top: 1px var(--style, solid) var(--color); }",
+			errors: [
+				{
+					messageId: "unknownVar",
+					data: {
+						var: "--color",
+					},
+					line: 1,
+					column: 45,
+					endLine: 1,
+					endColumn: 52,
+				},
+			],
+		},
+		{
+			code: ":root { --style: foo }\na { border-top: 1px var(--style) var(--color, red); }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "border-top",
+						value: "foo",
+						expected: "<line-width> || <line-style> || <color>",
+					},
+					line: 2,
+					column: 21,
+					endLine: 2,
+					endColumn: 33,
+				},
+			],
+		},
+		{
+			code: ":root { --style: foo }\na { border-top: 1px var(--style, solid) var(--color, red); }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "border-top",
+						value: "foo",
+						expected: "<line-width> || <line-style> || <color>",
+					},
+					line: 2,
+					column: 21,
+					endLine: 2,
+					endColumn: 40,
+				},
+			],
+		},
+		{
+			code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color); }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "border-top",
+						value: "foo",
+						expected: "<line-width> || <line-style> || <color>",
+					},
+					line: 2,
+					column: 58,
+					endLine: 2,
+					endColumn: 70,
+				},
+			],
+		},
+		{
+			code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback)) var(--color); }",
+			options: [{ allowUnknownVariables: true }],
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "border-top",
+						value: "foo",
+						expected: "<line-width> || <line-style> || <color>",
+					},
+					line: 2,
+					column: 51,
+					endLine: 2,
+					endColumn: 63,
+				},
+			],
+		},
+		{
+			code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback)) var(--color); }",
+			errors: [
+				{
+					messageId: "unknownVar",
+					data: {
+						var: "--style",
+					},
+					line: 2,
+					column: 25,
+					endLine: 2,
+					endColumn: 32,
+				},
+			],
+		},
+		{
+			code: ":root { --color: red }\na { colorr: var(--color, blue); }",
+			errors: [
+				{
+					messageId: "unknownProperty",
+					data: {
+						property: "colorr",
+					},
+					line: 2,
+					column: 5,
+					endLine: 2,
+					endColumn: 11,
+				},
+			],
+		},
 	],
 });