diff --git a/.changeset/tooltip-disabled-trigger-tab-order.md b/.changeset/tooltip-disabled-trigger-tab-order.md
new file mode 100644
index 000000000..fd6deefbe
--- /dev/null
+++ b/.changeset/tooltip-disabled-trigger-tab-order.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": patch
+---
+
+`Tooltip` now keeps its own trigger out of the tab order while the tooltip is `disabled`. This applies only when Kumo renders the trigger (no `render` or `asChild`), for example around truncated `Text` that needs a tooltip only when it overflows. A disabled tooltip there has nothing to reveal, so keyboard users no longer stop on it. Custom triggers passed through `render` keep their own focus behavior.
diff --git a/packages/kumo/src/components/tooltip/tooltip.test.tsx b/packages/kumo/src/components/tooltip/tooltip.test.tsx
index 80f6783de..26a1a5822 100644
--- a/packages/kumo/src/components/tooltip/tooltip.test.tsx
+++ b/packages/kumo/src/components/tooltip/tooltip.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vite-plus/test";
import { Text } from "../text/text";
import { Tooltip } from "./tooltip";
@@ -20,4 +21,75 @@ describe("Tooltip", () => {
expect(trigger).not.toBeNull();
expect(trigger?.className).not.toMatch(/\bleading-/);
});
+
+ describe("trigger tab order", () => {
+ function renderBetweenButtons(tooltip: React.ReactNode) {
+ render(
+ <>
+
+ {tooltip}
+
+ >,
+ );
+ }
+
+ it("keeps Kumo's trigger out of the tab order when disabled", async () => {
+ renderBetweenButtons(
+
+ FULL_VARIABLE_NAME
+ ,
+ );
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Before" }));
+ await user.tab();
+
+ expect(
+ screen
+ .getByRole("button", { name: "FULL_VARIABLE_NAME" })
+ .getAttribute("tabindex"),
+ ).toBe("-1");
+ expect(document.activeElement).toBe(
+ screen.getByRole("button", { name: "After" }),
+ );
+ });
+
+ it("keeps Kumo's trigger focusable when enabled", async () => {
+ renderBetweenButtons(
+
+ FULL_VARIABLE_NAME
+ ,
+ );
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Before" }));
+ await user.tab();
+
+ const trigger = screen.getByRole("button", {
+ name: "FULL_VARIABLE_NAME",
+ });
+ expect(trigger.hasAttribute("tabindex")).toBe(false);
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it("leaves a custom trigger's focus behavior alone when disabled", async () => {
+ renderBetweenButtons(
+ }
+ >
+ Save
+ ,
+ );
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole("button", { name: "Before" }));
+ await user.tab();
+
+ const trigger = screen.getByRole("button", { name: "Save" });
+ expect(trigger.hasAttribute("tabindex")).toBe(false);
+ expect(document.activeElement).toBe(trigger);
+ });
+ });
});
diff --git a/packages/kumo/src/components/tooltip/tooltip.tsx b/packages/kumo/src/components/tooltip/tooltip.tsx
index 456ddb7c6..0eb5485c2 100644
--- a/packages/kumo/src/components/tooltip/tooltip.tsx
+++ b/packages/kumo/src/components/tooltip/tooltip.tsx
@@ -161,6 +161,10 @@ export function Tooltip({
render ?? (asChild ? (children as TriggerProps["render"]) : undefined);
const shouldUseRender = resolvedRender !== undefined;
+ // A disabled tooltip has nothing to reveal, so keep Kumo's own trigger
+ // (usually plain text) out of the tab order. Custom triggers keep theirs.
+ const triggerTabIndex = !shouldUseRender && props.disabled ? -1 : undefined;
+
return (
{asChild ? undefined : (children as ReactNode)}