diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index 969eb8e12f0fe..9b002d4cd0db2 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -243,6 +243,7 @@ mod jsx_a11y {
pub mod alt_text;
pub mod anchor_has_content;
pub mod anchor_is_valid;
+ pub mod aria_activedescendant_has_tabindex;
pub mod aria_props;
pub mod aria_role;
pub mod aria_unsupported_elements;
@@ -507,6 +508,7 @@ oxc_macros::declare_all_lint_rules! {
jsx_a11y::alt_text,
jsx_a11y::anchor_has_content,
jsx_a11y::anchor_is_valid,
+ jsx_a11y::aria_activedescendant_has_tabindex,
jsx_a11y::aria_props,
jsx_a11y::aria_unsupported_elements,
jsx_a11y::click_events_have_key_events,
diff --git a/crates/oxc_linter/src/rules/jsx_a11y/aria_activedescendant_has_tabindex.rs b/crates/oxc_linter/src/rules/jsx_a11y/aria_activedescendant_has_tabindex.rs
new file mode 100644
index 0000000000000..7b604dbd6ef40
--- /dev/null
+++ b/crates/oxc_linter/src/rules/jsx_a11y/aria_activedescendant_has_tabindex.rs
@@ -0,0 +1,151 @@
+use oxc_ast::{
+ ast::{JSXAttribute, JSXAttributeItem, JSXElementName},
+ AstKind,
+};
+use oxc_diagnostics::{
+ miette::{self, Diagnostic},
+ thiserror::Error,
+};
+use oxc_macros::declare_oxc_lint;
+use oxc_span::Span;
+
+use crate::{
+ context::LintContext,
+ globals::HTML_TAG,
+ rule::Rule,
+ utils::{get_element_type, has_jsx_prop_lowercase, is_interactive_element, parse_jsx_value},
+ AstNode,
+};
+
+#[derive(Debug, Error, Diagnostic)]
+#[error("eslint-plugin-jsx-a11y(aria-activedescendant-has-tabindex): Enforce elements with aria-activedescendant are tabbable.")]
+#[diagnostic(
+ severity(warning),
+ help("An element that manages focus with `aria-activedescendant` must have a tabindex.")
+)]
+struct AriaActivedescendantHasTabindexDiagnostic(#[label] pub Span);
+
+#[derive(Debug, Default, Clone)]
+pub struct AriaActivedescendantHasTabindex;
+
+declare_oxc_lint!(
+ /// ### What it does
+ ///
+ /// Enforce elements with aria-activedescendant are tabbable.
+ ///
+ /// ### Example
+ /// ```jsx
+ /// // Good
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// // Bad
+ ///
+ /// ```
+ AriaActivedescendantHasTabindex,
+ correctness
+);
+
+impl Rule for AriaActivedescendantHasTabindex {
+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
+ let AstKind::JSXOpeningElement(jsx_opening_el) = node.kind() else {
+ return;
+ };
+
+ if has_jsx_prop_lowercase(jsx_opening_el, "aria-activedescendant").is_none() {
+ return;
+ };
+
+ let Some(element_type) = get_element_type(ctx, jsx_opening_el) else {
+ return;
+ };
+
+ if !HTML_TAG.contains(&element_type) {
+ return;
+ };
+
+ if let Some(JSXAttributeItem::Attribute(tab_index_attr)) =
+ has_jsx_prop_lowercase(jsx_opening_el, "tabIndex")
+ {
+ if !is_valid_tab_index_attr(tab_index_attr) {
+ return;
+ }
+ } else if is_interactive_element(&element_type, jsx_opening_el) {
+ return;
+ }
+
+ let JSXElementName::Identifier(identifier) = &jsx_opening_el.name else {
+ return;
+ };
+
+ ctx.diagnostic(AriaActivedescendantHasTabindexDiagnostic(identifier.span));
+ }
+}
+
+fn is_valid_tab_index_attr(attr: &JSXAttribute) -> bool {
+ attr.value
+ .as_ref()
+ .and_then(|value| parse_jsx_value(value).ok())
+ .map_or(false, |parsed_value| parsed_value < -1.0)
+}
+
+#[test]
+fn test() {
+ use crate::tester::Tester;
+
+ fn settings() -> serde_json::Value {
+ serde_json::json!({
+ "jsx-a11y": {
+ "components": {
+ "CustomComponent": "div",
+ }
+ }
+ })
+ }
+
+ let pass = vec![
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (
+ r";",
+ None,
+ Some(settings()),
+ None,
+ ),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ (r";", None, None, None),
+ ];
+
+ let fail = vec![
+ (r";", None, None, None),
+ (r";", None, Some(settings()), None),
+ ];
+
+ Tester::new(AriaActivedescendantHasTabindex::NAME, pass, fail).test_and_snapshot();
+}
diff --git a/crates/oxc_linter/src/snapshots/aria_activedescendant_has_tabindex.snap b/crates/oxc_linter/src/snapshots/aria_activedescendant_has_tabindex.snap
new file mode 100644
index 0000000000000..ad20d3880b1d5
--- /dev/null
+++ b/crates/oxc_linter/src/snapshots/aria_activedescendant_has_tabindex.snap
@@ -0,0 +1,19 @@
+---
+source: crates/oxc_linter/src/tester.rs
+expression: aria_activedescendant_has_tabindex
+---
+ ⚠ eslint-plugin-jsx-a11y(aria-activedescendant-has-tabindex): Enforce elements with aria-activedescendant are tabbable.
+ ╭─[aria_activedescendant_has_tabindex.tsx:1:1]
+ 1 │ ;
+ · ───
+ ╰────
+ help: An element that manages focus with `aria-activedescendant` must have a tabindex.
+
+ ⚠ eslint-plugin-jsx-a11y(aria-activedescendant-has-tabindex): Enforce elements with aria-activedescendant are tabbable.
+ ╭─[aria_activedescendant_has_tabindex.tsx:1:1]
+ 1 │ ;
+ · ───────────────
+ ╰────
+ help: An element that manages focus with `aria-activedescendant` must have a tabindex.
+
+