diff --git a/.changeset/gold-pigs-carry.md b/.changeset/gold-pigs-carry.md
new file mode 100644
index 00000000..6320c376
--- /dev/null
+++ b/.changeset/gold-pigs-carry.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-primer-react': minor
+---
+
+Add `a11y-no-duplicate-form-labels` rule to prevent duplicate labels on TextInput components.
diff --git a/docs/rules/a11y-no-duplicate-form-labels.md b/docs/rules/a11y-no-duplicate-form-labels.md
new file mode 100644
index 00000000..7dc9411b
--- /dev/null
+++ b/docs/rules/a11y-no-duplicate-form-labels.md
@@ -0,0 +1,48 @@
+## Rule Details
+
+This rule prevents accessibility issues by ensuring form controls have only one label. When a `FormControl` contains both a `FormControl.Label` and a `TextInput` with an `aria-label`, it creates duplicate labels which can confuse screen readers and other assistive technologies.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+import {FormControl, TextInput} from '@primer/react'
+
+function ExampleComponent() {
+ return (
+ // TextInput has aria-label when FormControl.Label is present
+
+ Form Input Label
+
+
+ )
+}
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+import {FormControl, TextInput} from '@primer/react'
+
+function ExampleComponent() {
+ return (
+ <>
+ {/* TextInput without aria-label when FormControl.Label is present */}
+
+ Form Input Label
+
+
+
+ {/* TextInput with aria-label when no FormControl.Label is present */}
+
+
+
+
+ {/* Using visuallyHidden FormControl.Label without aria-label */}
+
+ Form Input Label
+
+
+ >
+ )
+}
+```
diff --git a/src/configs/recommended.js b/src/configs/recommended.js
index 61cd07cc..28b26b96 100644
--- a/src/configs/recommended.js
+++ b/src/configs/recommended.js
@@ -17,6 +17,7 @@ module.exports = {
'primer-react/new-color-css-vars': 'error',
'primer-react/a11y-explicit-heading': 'error',
'primer-react/a11y-no-title-usage': 'error',
+ 'primer-react/a11y-no-duplicate-form-labels': 'error',
'primer-react/no-deprecated-props': 'warn',
'primer-react/a11y-remove-disable-tooltip': 'error',
'primer-react/a11y-use-accessible-tooltip': 'error',
diff --git a/src/index.js b/src/index.js
index 1cb3a431..68de6f20 100644
--- a/src/index.js
+++ b/src/index.js
@@ -12,6 +12,7 @@ module.exports = {
'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'),
'a11y-use-accessible-tooltip': require('./rules/a11y-use-accessible-tooltip'),
'a11y-no-title-usage': require('./rules/a11y-no-title-usage'),
+ 'a11y-no-duplicate-form-labels': require('./rules/a11y-no-duplicate-form-labels'),
'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
'no-wildcard-imports': require('./rules/no-wildcard-imports'),
'no-unnecessary-components': require('./rules/no-unnecessary-components'),
diff --git a/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js b/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js
new file mode 100644
index 00000000..a54f7cc6
--- /dev/null
+++ b/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js
@@ -0,0 +1,112 @@
+const rule = require('../a11y-no-duplicate-form-labels')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+})
+
+ruleTester.run('a11y-no-duplicate-form-labels', rule, {
+ valid: [
+ // TextInput without aria-label is valid
+ `import {FormControl, TextInput} from '@primer/react';
+
+ Form Input Label
+
+ `,
+
+ // TextInput with aria-label but no FormControl.Label is valid
+ `import {FormControl, TextInput} from '@primer/react';
+
+
+ `,
+
+ // TextInput with aria-label outside FormControl is valid
+ `import {TextInput} from '@primer/react';
+ `,
+
+ // TextInput with visuallyHidden FormControl.Label is valid
+ `import {FormControl, TextInput} from '@primer/react';
+
+ Form Input Label
+
+ `,
+
+ // FormControl without FormControl.Label but with aria-label is valid
+ `import {FormControl, TextInput} from '@primer/react';
+
+
+ `,
+
+ // Multiple TextInputs with different approaches
+ `import {FormControl, TextInput} from '@primer/react';
+
+
+ Visible Label
+
+
+
+
+
+
`,
+ ],
+ invalid: [
+ {
+ code: `import {FormControl, TextInput} from '@primer/react';
+
+ Form Input Label
+
+ `,
+ errors: [
+ {
+ messageId: 'duplicateLabel',
+ },
+ ],
+ },
+ {
+ code: `import {FormControl, TextInput} from '@primer/react';
+
+ Username
+
+ `,
+ errors: [
+ {
+ messageId: 'duplicateLabel',
+ },
+ ],
+ },
+ {
+ code: `import {FormControl, TextInput} from '@primer/react';
+
+ Password
+
+ `,
+ errors: [
+ {
+ messageId: 'duplicateLabel',
+ },
+ ],
+ },
+ {
+ code: `import {FormControl, TextInput} from '@primer/react';
+ `,
+ errors: [
+ {
+ messageId: 'duplicateLabel',
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/a11y-no-duplicate-form-labels.js b/src/rules/a11y-no-duplicate-form-labels.js
new file mode 100644
index 00000000..7dc4f2c4
--- /dev/null
+++ b/src/rules/a11y-no-duplicate-form-labels.js
@@ -0,0 +1,69 @@
+const {isPrimerComponent} = require('../utils/is-primer-component')
+const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
+const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
+
+const isFormControl = node => getJSXOpeningElementName(node) === 'FormControl'
+const isFormControlLabel = node => getJSXOpeningElementName(node) === 'FormControl.Label'
+const isTextInput = node => getJSXOpeningElementName(node) === 'TextInput'
+
+const hasAriaLabel = node => {
+ const ariaLabel = getJSXOpeningElementAttribute(node, 'aria-label')
+ return !!ariaLabel
+}
+
+const findFormControlLabel = (node, sourceCode) => {
+ // Traverse up the parent chain to find FormControl
+ let current = node.parent
+ while (current) {
+ if (
+ current.type === 'JSXElement' &&
+ isFormControl(current.openingElement) &&
+ isPrimerComponent(current.openingElement.name, sourceCode.getScope(current))
+ ) {
+ // Found FormControl, now check if it has a FormControl.Label child
+ return current.children.some(
+ child =>
+ child.type === 'JSXElement' &&
+ isFormControlLabel(child.openingElement) &&
+ isPrimerComponent(child.openingElement.name, sourceCode.getScope(child)),
+ )
+ }
+ current = current.parent
+ }
+ return false
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Prevent duplicate labels on form inputs by disallowing aria-label on TextInput when FormControl.Label is present.',
+ url: require('../url')(module),
+ },
+ schema: [],
+ messages: {
+ duplicateLabel:
+ 'TextInput should not have aria-label when FormControl.Label is present. Use FormControl.Label with visuallyHidden prop if needed.',
+ },
+ },
+ create(context) {
+ const sourceCode = context.sourceCode ?? context.getSourceCode()
+ return {
+ JSXOpeningElement(jsxNode) {
+ if (isPrimerComponent(jsxNode.name, sourceCode.getScope(jsxNode)) && isTextInput(jsxNode)) {
+ // Check if TextInput has aria-label
+ if (hasAriaLabel(jsxNode)) {
+ // Check if there's a FormControl.Label in the parent FormControl
+ if (findFormControlLabel(jsxNode, sourceCode)) {
+ context.report({
+ node: jsxNode,
+ messageId: 'duplicateLabel',
+ })
+ }
+ }
+ }
+ },
+ }
+ },
+}