# Tooltip

**When to use:** Contextual help, info icons, field explanations, error pointers.

**Keywords:** hint, help text, info bubble

**Import:** `import { Tooltip } from '@ahrowe/ui'`  
**Types:** `import type { TooltipProps, TooltipVariant, TooltipLabels } from '@ahrowe/ui'`

```tsx
import { Tooltip } from '@ahrowe/ui';

// Info tooltip (always visible)
<Tooltip variant="info" message="This field is used for billing." isVisible />

// Error tooltip (shown on validation failure)
<Tooltip variant="error" message="Email is required." isVisible={hasError} />

// Details variant
<Tooltip variant="details" message="Last updated: 2 hours ago" isVisible={showDetails} />

// Controlled visibility
<Tooltip
  variant="info"
  message="Click to learn more"
  isVisible={isHovered}
  onClick={handleLearnMore}
/>
```

**TooltipVariant:** `'info'` | `'details'` | `'error'`

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `variant` | `TooltipVariant` | Visual style (required) |
| `message` | `ReactNode` | Tooltip content |
| `isVisible` | `boolean` | Controls visibility |
| `iconPosition` | `string` | CSS positioning for the icon |
| `tooltipPosition` | `string` | CSS positioning for the bubble |
| `onClick` | `() => void` | Click handler on the icon |
| `labels` | `TooltipLabels` | Overrides the text Tooltip renders of its own. Merged per key |

**Labels:** `details` (accessible name of the `details` variant's `?` badge, default `More information`).

## Accessibility

The bubble carries `role="tooltip"`, and while it is shown the element it sits inside gains an
`aria-describedby` pointing at it, so a screen reader reads the tooltip as the description of the
field or control it explains. The attribute is removed again when the bubble goes.

The `error` variant is `role="alert"` instead: it appears in answer to something the user just
did, and a description is only read out when its trigger is focused, which a field the user has
already left is not.

The `details` badge is a real control. It takes focus, opens on focus as well as hover, toggles on
Enter and Space, and reports its state through `aria-expanded`. Give it a translated name through
`labels.details`.

```tsx
<Tooltip variant="details" message="Last updated: 2 hours ago" labels={{ details: 'Mehr Informationen' }} />
```

The bubble is rendered through a portal and tracks its trigger's position directly, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists) — no `position: relative` wrapper is required. It flips to the opposite side automatically when there's no room in its preferred direction, and hides (without losing state) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view.

**Theming:** these are theme variables (typed on `ThemeVariables`). Set them theme-wide via `ThemeProvider`'s `variables`, or per instance via `style`, without fighting specificity. Each falls back to a built-in default when unset:

| Variable | Falls back to |
|----------|---------------|
| `--tooltip-background` | `var(--text-color)` mixed 82% into `var(--background)` |
| `--tooltip-color` | `var(--background)` |

The default bubble is an **inverse of the page** rather than a fixed colour: it comes out dark on a light theme and light on a dark one, so it always separates from whatever it floats over. Both the arrow and the `details` badge follow `--tooltip-background`, so overriding it recolours the whole tooltip. The `error` variant is styled from the semantic error colours (`--error-lighter`, `--error-color`, `--error-border-color`) and ignores these two.

## Sizing

The bubble, its arrow, the `details` badge and the error variant all scale with the theme's text. The bubble's `max-width` is `em`, so it bounds a line measure rather than a pixel count as the text grows.

The badge derives its size from `--default-font-size` directly rather than from `em`. It renders inline wherever you put it, so an `em` would measure the surrounding text instead of the theme; the bubble can use `em` because it is portalled to the end of the app and always lands on the theme's own size.
