Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
Demo
Accessibility Features
WAI-ARIA Roles
-
tooltip- A contextual popup that displays a description for an element
WAI-ARIA tooltip role (opens in new tab)
WAI-ARIA States & Properties
aria-describedby
References the tooltip element to provide an accessible description for the trigger element.
| Applied to | Trigger element (wrapper) |
| When | Only when tooltip is visible |
| Reference | aria-describedby (opens in new tab) |
aria-hidden
Indicates whether the tooltip is hidden from assistive technology.
| Values | true (hidden) | false (visible)
|
| Default | true |
| Reference | aria-hidden (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Escape | Closes the tooltip |
| Tab | Standard focus navigation; tooltip shows when trigger receives focus |
Focus Management
- Tooltip never receives focus - Per APG, tooltips must not be focusable. If interactive content is needed, use a Dialog or Popover pattern instead.
- Focus triggers display - When the trigger element receives focus, the tooltip appears after the configured delay.
- Blur hides tooltip - When focus leaves the trigger element, the tooltip is hidden.
Mouse/Pointer Behavior
- Hover triggers display - Moving the pointer over the trigger shows the tooltip after the delay.
- Pointer leave hides - Moving the pointer away from the trigger hides the tooltip.
Important Notes
Note: The APG Tooltip pattern is currently marked as "work in progress" by the WAI. This implementation follows the documented guidelines, but the specification may evolve. View APG Tooltip Pattern (opens in new tab)
Visual Design
This implementation follows best practices for tooltip visibility:
- High contrast - Dark background with light text ensures readability
- Dark mode support - Colors invert appropriately in dark mode
- Positioned near trigger - Tooltip appears adjacent to the triggering element
- Configurable delay - Prevents accidental activation during cursor movement
Source Code
Tooltip.svelte
<script lang="ts" module>
import type { Snippet } from "svelte";
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
export interface TooltipProps {
/** Tooltip content - can be string or Snippet for rich content */
content: string | Snippet;
/** Trigger element - must be a focusable element for keyboard accessibility */
children?: Snippet<[{ describedBy: string | undefined }]>;
/** Controlled open state */
open?: boolean;
/** Default open state (uncontrolled) */
defaultOpen?: boolean;
/** Delay before showing tooltip (ms) */
delay?: number;
/** Tooltip placement */
placement?: TooltipPlacement;
/**
* Tooltip ID - Required for SSR/hydration consistency.
* Must be unique and stable across server and client renders.
*/
id: string;
/** Whether the tooltip is disabled */
disabled?: boolean;
/** Additional class name for the wrapper */
class?: string;
/** Additional class name for the tooltip content */
tooltipClass?: string;
}
</script>
<script lang="ts">
import { cn } from "@/lib/utils";
import { onDestroy } from "svelte";
let {
content,
children,
open: controlledOpen = undefined,
defaultOpen = false,
delay = 300,
placement = "top",
id,
disabled = false,
class: className = "",
tooltipClass = "",
onOpenChange,
}: TooltipProps & { onOpenChange?: (open: boolean) => void } = $props();
// Use provided id directly - required for SSR/hydration consistency
const tooltipId = $derived(id);
let internalOpen = $state(defaultOpen);
let timeout: ReturnType<typeof setTimeout> | null = null;
let isControlled = $derived(controlledOpen !== undefined);
let isOpen = $derived(isControlled ? controlledOpen : internalOpen);
// aria-describedby should always be set when not disabled for screen reader accessibility
// This ensures SR users know the element has a description even before tooltip is visible
let describedBy = $derived(!disabled ? tooltipId : undefined);
function setOpen(value: boolean) {
if (controlledOpen === undefined) {
internalOpen = value;
}
onOpenChange?.(value);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
hideTooltip();
}
}
function showTooltip() {
if (disabled) return;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
setOpen(true);
}, delay);
}
function hideTooltip() {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
setOpen(false);
}
// Manage keydown listener based on isOpen state
// This handles both controlled and uncontrolled modes
$effect(() => {
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
} else {
document.removeEventListener("keydown", handleKeyDown);
}
});
// Cleanup on destroy - fix for memory leak
onDestroy(() => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
document.removeEventListener("keydown", handleKeyDown);
});
const placementClasses: Record<TooltipPlacement, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class={cn("apg-tooltip-trigger", "relative inline-block", className)}
onmouseenter={showTooltip}
onmouseleave={hideTooltip}
onfocusin={showTooltip}
onfocusout={hideTooltip}
>
{#if children}
{@render children({ describedBy })}
{/if}
<span
id={tooltipId}
role="tooltip"
aria-hidden={!isOpen}
class={cn(
"apg-tooltip",
"absolute z-50 px-3 py-1.5 text-sm",
"bg-gray-900 text-white rounded-md shadow-lg",
"dark:bg-gray-100 dark:text-gray-900",
"pointer-events-none whitespace-nowrap",
"transition-opacity duration-150",
placementClasses[placement],
isOpen ? "opacity-100 visible" : "opacity-0 invisible",
tooltipClass
)}
>
{#if typeof content === "string"}
{content}
{:else}
{@render content()}
{/if}
</span>
</span> Usage
Example
<script>
import Tooltip from './Tooltip.svelte';
</script>
<!-- Basic usage with render props for aria-describedby -->
<Tooltip
id="tooltip-save"
content="Save your changes"
placement="top"
delay={300}
>
{#snippet children({ describedBy })}
<button aria-describedby={describedBy}>Save</button>
{/snippet}
</Tooltip>
<!-- Rich content using Snippet -->
<Tooltip id="tooltip-shortcut">
{#snippet content()}
<span class="flex items-center gap-1">
<kbd>Ctrl</kbd>+<kbd>S</kbd>
</span>
{/snippet}
{#snippet children({ describedBy })}
<button aria-describedby={describedBy}>Keyboard shortcut</button>
{/snippet}
</Tooltip> API
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the tooltip (required for SSR/hydration consistency) |
content | string | Snippet | - | Tooltip content (required) |
children | Snippet<[{ describedBy }]> | - | Render props pattern - receives describedBy for aria-describedby |
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Default open state (uncontrolled) |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
delay | number | 300 | Delay before showing (ms) |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | Tooltip position |
disabled | boolean | false | Disable the tooltip |
Testing
Testing Overview
The Tooltip component tests are organized into priority levels based on APG compliance requirements.
Test Categories
High Priority: APG Core Compliance
| Test | APG Requirement |
|---|---|
| role="tooltip" exists | Tooltip container must have tooltip role |
| aria-hidden when closed | Hidden tooltips must not be read by AT |
| aria-describedby when visible | Trigger must reference tooltip only when visible |
| Escape key closes tooltip | Keyboard dismissal support |
| Focus shows tooltip | Keyboard accessibility |
| Blur hides tooltip | Focus management |
Medium Priority: Accessibility Validation
| Test | WCAG Requirement |
|---|---|
| No axe violations (hidden state) | WCAG 2.1 AA compliance |
| No axe violations (visible state) | WCAG 2.1 AA compliance |
| Tooltip is not focusable | APG: tooltips must not receive focus |
Low Priority: Props & Extensibility
| Test | Feature |
|---|---|
| placement prop changes position | Positioning customization |
| disabled prop prevents display | Disable functionality |
| delay prop controls timing | Delay customization |
| id prop sets custom ID | SSR/custom ID support |
| controlled open state | External state control |
| onOpenChange callback | State change notification |
| className inheritance | Style customization |
Running Tests
# Run all Tooltip tests
npm run test -- tooltip
# Run tests for specific framework
npm run test -- Tooltip.test.tsx # React
npm run test -- Tooltip.test.vue # Vue
npm run test -- Tooltip.test.svelte # Svelte Tooltip.test.svelte.ts
import { render, screen, waitFor } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import TooltipTestWrapper from "./test-wrappers/TooltipTestWrapper.svelte";
describe("Tooltip (Svelte)", () => {
describe("APG: ARIA ๅฑๆง", () => {
it('role="tooltip" ใๆใค', () => {
render(TooltipTestWrapper, { props: { content: "This is a tooltip" } });
expect(screen.getByRole("tooltip", { hidden: true })).toBeInTheDocument();
});
it("้่กจ็คบๆใฏ aria-hidden ใ true", () => {
render(TooltipTestWrapper, { props: { content: "This is a tooltip" } });
const tooltip = screen.getByRole("tooltip", { hidden: true });
expect(tooltip).toHaveAttribute("aria-hidden", "true");
});
it("่กจ็คบๆใฏ aria-hidden ใ false", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "This is a tooltip", delay: 0 } });
const trigger = screen.getByRole("button");
await user.hover(trigger);
await waitFor(() => {
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveAttribute("aria-hidden", "false");
});
});
});
describe("APG: ใญใผใใผใๆไฝ", () => {
it("Escape ใญใผใง้ใใ", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "This is a tooltip", delay: 0 } });
const trigger = screen.getByRole("button");
await user.hover(trigger);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveAttribute("aria-hidden", "false");
});
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute("aria-hidden", "true");
});
});
it("ใใฉใผใซในใง่กจ็คบใใใ", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "This is a tooltip", delay: 0 } });
await user.tab();
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveAttribute("aria-hidden", "false");
});
});
});
describe("ใใใผๆไฝ", () => {
it("ใใใผใง่กจ็คบใใใ", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "This is a tooltip", delay: 0 } });
await user.hover(screen.getByRole("button"));
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveAttribute("aria-hidden", "false");
});
});
it("ใใใผ่งฃ้คใง้ใใ", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "This is a tooltip", delay: 0 } });
const trigger = screen.getByRole("button");
await user.hover(trigger);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveAttribute("aria-hidden", "false");
});
await user.unhover(trigger);
await waitFor(() => {
expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute("aria-hidden", "true");
});
});
});
describe("ใขใฏใปใทใใชใใฃ", () => {
it("axe ใซใใ WCAG 2.1 AA ้ๅใใชใ", async () => {
const { container } = render(TooltipTestWrapper, { props: { content: "This is a tooltip" } });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("tooltip ใใใฉใผใซในใๅใๅใใชใ", () => {
render(TooltipTestWrapper, { props: { content: "This is a tooltip" } });
const tooltip = screen.getByRole("tooltip", { hidden: true });
expect(tooltip).not.toHaveAttribute("tabindex");
});
});
describe("Props", () => {
it("placement prop ใงไฝ็ฝฎใๅคๆดใงใใ", () => {
render(TooltipTestWrapper, { props: { content: "Tooltip", placement: "bottom" } });
const tooltip = screen.getByRole("tooltip", { hidden: true });
expect(tooltip).toHaveClass("top-full");
});
it("disabled ใฎๅ ดๅใtooltip ใ่กจ็คบใใใชใ", async () => {
const user = userEvent.setup();
render(TooltipTestWrapper, { props: { content: "Tooltip", delay: 0, disabled: true } });
await user.hover(screen.getByRole("button"));
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByRole("tooltip", { hidden: true })).toHaveAttribute("aria-hidden", "true");
});
it("id prop ใงใซในใฟใ ID ใ่จญๅฎใงใใ", () => {
render(TooltipTestWrapper, { props: { content: "Tooltip", id: "custom-tooltip-id" } });
const tooltip = screen.getByRole("tooltip", { hidden: true });
expect(tooltip).toHaveAttribute("id", "custom-tooltip-id");
});
});
});