diff --git a/src/components/PlanBadge.test.tsx b/src/components/PlanBadge.test.tsx
new file mode 100644
index 0000000..1a6a5d1
--- /dev/null
+++ b/src/components/PlanBadge.test.tsx
@@ -0,0 +1,29 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import PlanBadge from "./PlanBadge";
+
+describe("PlanBadge", () => {
+ afterEach(() => cleanup());
+
+ it("renders the tier label", () => {
+ render();
+ expect(screen.getByText("Pro")).toBeTruthy();
+ });
+
+ it("exposes rate-limit details in the accessible name", () => {
+ render();
+ expect(
+ screen.getByLabelText(/free plan\. rate limit: 60 requests \/ minute\./i),
+ ).toBeTruthy();
+ });
+
+ it("reveals a tooltip with rate-limit details on focus", () => {
+ render();
+ fireEvent.focus(screen.getByText("Enterprise"));
+ const tip = screen.getByRole("tooltip");
+ expect(tip.textContent).toContain("Enterprise plan");
+ expect(tip.textContent).toContain("Custom / unmetered");
+ });
+});
diff --git a/src/components/PlanBadge.tsx b/src/components/PlanBadge.tsx
new file mode 100644
index 0000000..dba5066
--- /dev/null
+++ b/src/components/PlanBadge.tsx
@@ -0,0 +1,92 @@
+import Tooltip from "./Tooltip";
+
+/**
+ * PlanBadge — a small pill that labels an API/account plan tier and, on
+ * hover / focus / long-press, explains the tier with its rate-limit details
+ * via an accessible {@link Tooltip} (issue #283).
+ */
+
+export type PlanTier = "free" | "pro" | "enterprise";
+
+type PlanMeta = {
+ label: string;
+ /** Human-readable rate limit shown in the tooltip. */
+ rateLimit: string;
+ description: string;
+ /** Token names that resolve to theme-aware colours. */
+ bg: string;
+ fg: string;
+};
+
+const PLAN_META: Record = {
+ free: {
+ label: "Free",
+ rateLimit: "60 requests / minute",
+ description: "Best for prototyping and evaluation.",
+ bg: "var(--plan-free-bg, #e5e7eb)",
+ fg: "var(--plan-free-fg, #374151)",
+ },
+ pro: {
+ label: "Pro",
+ rateLimit: "1,000 requests / minute",
+ description: "Higher throughput and priority support for production apps.",
+ bg: "var(--plan-pro-bg, #dbeafe)",
+ fg: "var(--plan-pro-fg, #1e40af)",
+ },
+ enterprise: {
+ label: "Enterprise",
+ rateLimit: "Custom / unmetered",
+ description: "Dedicated capacity, SLAs, and custom rate limits.",
+ bg: "var(--plan-enterprise-bg, #ede9fe)",
+ fg: "var(--plan-enterprise-fg, #5b21b6)",
+ },
+};
+
+type PlanBadgeProps = {
+ tier: PlanTier;
+ className?: string;
+};
+
+export default function PlanBadge({
+ tier,
+ className,
+}: PlanBadgeProps): JSX.Element {
+ const meta = PLAN_META[tier];
+
+ const tooltipContent = (
+
+
+ {meta.label} plan
+
+ {meta.description}
+
+ Rate limit: {meta.rateLimit}
+
+
+ );
+
+ return (
+
+
+ {meta.label}
+
+
+ );
+}
diff --git a/src/components/Tooltip.test.tsx b/src/components/Tooltip.test.tsx
new file mode 100644
index 0000000..e953042
--- /dev/null
+++ b/src/components/Tooltip.test.tsx
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import Tooltip from "./Tooltip";
+
+describe("Tooltip", () => {
+ afterEach(() => cleanup());
+
+ it("is hidden by default", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("shows on hover and hides on leave", () => {
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByText("Trigger");
+ fireEvent.mouseEnter(trigger);
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ fireEvent.mouseLeave(trigger);
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("shows on keyboard focus and links via aria-describedby", () => {
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByText("Trigger");
+ fireEvent.focus(trigger);
+ const tip = screen.getByRole("tooltip");
+ expect(trigger.getAttribute("aria-describedby")).toBe(tip.id);
+ });
+
+ it("dismisses on Escape", () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.mouseEnter(screen.getByText("Trigger"));
+ expect(screen.getByRole("tooltip")).toBeTruthy();
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+});
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx
new file mode 100644
index 0000000..24445a7
--- /dev/null
+++ b/src/components/Tooltip.tsx
@@ -0,0 +1,120 @@
+import {
+ cloneElement,
+ isValidElement,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+
+/**
+ * Tooltip — an accessible tooltip that opens on hover, keyboard focus, and
+ * touch long-press (issue #283).
+ *
+ * Accessibility (WCAG 2.1 AA):
+ * - The trigger gets `aria-describedby` pointing at the tooltip content.
+ * - Content has `role="tooltip"`.
+ * - Escape dismisses an open tooltip.
+ * - Colours come from design tokens so it reads in light and dark mode.
+ */
+
+type TooltipProps = {
+ /** Tooltip body. Plain text or rich nodes. */
+ content: ReactNode;
+ /** Single focusable trigger element (e.g. a or