# Tooltip

> Hover/focus restatement. Never the only home of a fact.

Source: https://lopifi.com/components/tooltip
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

- **Family:** Primitives — The layer everything else composes. Build these first; nothing above them is right until they are.
- **Status:** Implemented
- **Spec:** https://lopifi.com/docs/catalog/01-primitives

## Install

```bash
npx shadcn@latest add @lopifi/theme
npx shadcn@latest add @lopifi/tooltip
```

## Specification

> A restatement of something already on screen. Never the only home of a fact.

```tsx
<Tooltip content="30 Sep 2026, 09:12 UTC">
  <time>2m ago</time>
</Tooltip>

<TooltipProvider delay={400}>{/* a toolbar, a table, an app */}</TooltipProvider>
```

```ts
interface TooltipProps {
  /** Nullable on purpose: a truncated cell has one, a short cell does not. */
  content: React.ReactNode;
  children: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  /** Overrides the provider's delay for this one trigger. */
  delay?: number;
  disabled?: boolean;
  /** `block` for a child that fills its column — a truncating cell. */
  display?: "inline" | "block";
  triggerClassName?: string;
}
```

**It renders its own trigger span** and puts `children` inside it, rather than cloning the
caller's element. That is not a stylistic choice: a Server Component passes its JSX children
across the boundary as a **lazy node**, not an element — `isValidElement` is false and `type` is
`undefined` — so a `render={children}` clone renders `undefined` and takes the page down with it.
Rendering the child as a child works from either side of the boundary. The cost is one `<span>`,
and `display="block"` is how a truncating child keeps its width.

**What may go in one.** The unabbreviated figure behind `$1.2M`. The absolute timestamp behind
"2m ago". The untruncated merchant name behind an ellipsis. The rate and instant behind a `≈`
conversion. The comparison basis behind a delta on a card too narrow to carry it inline. In every
case the tooltip restates — it never introduces.

**What may not.** Anything a user must read to act. **Tooltips do not exist on touch**, so a
sentence that only lives in one is a sentence a phone user never sees. If it is essential, inline
it; if it is additional, it belongs in an [`InfoTip`](#170-infotip), which opens on press.

**Layer 3 — structure:** radius 8, `--surface-raised`, hairline + `--elevation-2`, 12px caption,
max-width 264px, 8px from its anchor.

**Motion:** enter `opacity 0→1, scale 0.97→1` over 140ms `--ease-out`; exit opacity over 100ms;
400ms delay before the *first* one. Once one is open, hovering a neighbour opens it instantly —
Base UI sets `data-instant`, and the transition duration must go to `0ms` with it or the "instant"
tooltip still visibly fades. Wrap a dense surface in `TooltipProvider` to get that grouping.

**Accessibility:** the trigger keeps its own accessible name; the tooltip is `aria-describedby`.
It opens on keyboard focus, closes on Escape, and is never the target of a click.

## Rules this component obeys

Money is minor units, never floats. `null` is not `0` and neither is redacted. Negatives render in accounting parentheses with the parenthesis column reserved on both sides. Debit and credit are two columns. Per-currency decimal places. Polarity is explicit — up is not always good. Every figure states its period, entity, currency, basis and as-of.
