Tooltip
Preview
Code
ts
import Tooltip from '@/components/overlay/Tooltip';src/components/overlay/Tooltip.tsx
AI prompt
text
Build a portalled tooltip component (plus an ⓘ InfoTooltip) in React + TypeScript + Tailwind CSS.
## Look
- Trigger wrapper: an `inline-flex` span around `children`.
- Bubble: `fixed z-[200] pointer-events-none w-max rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed shadow-lg text-left font-normal normal-case tracking-normal`. No arrow, no animation.
- Wrapping is on by default: `whitespace-normal max-w-[16rem]` (`wide`: `max-w-[28rem]`). `multiline={false}` uses `whitespace-nowrap`.
- `variant="dark"` (default): `bg-slate-900 dark:bg-slate-700 text-white`. `variant="light"`, for use on a dark surface: `bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100`.
- `pointer-events-none` is deliberate: a hoverable bubble traps the pointer and flickers against its own trigger.
## Positioning
- Portalled to <body>, `position: fixed`, from the trigger's viewport rect with an 8px gap. The bubble is never measured: an anchor point plus a transform places it.
- top: `left = centreX`, `top = rect.top - 8`, `translate(-50%, -100%)`; bottom: `top = rect.bottom + 8`, `translate(-50%, 0)`.
- left: `top = centreY`, `left = rect.left - 8`, `translate(-100%, -50%)`; right: `left = rect.right + 8`, `translate(0, -50%)`.
- Flip: `top` becomes `bottom` when `rect.top < 96`; `bottom` becomes `top` when fewer than 96px remain below. Left and right never flip.
- For top/bottom, clamp the centre x to `[8 + 128, vw - 8 - 128]` (half the 256px max width), so a hint on the last column cannot run off-screen. If the viewport is too narrow for the clamp, centre in the viewport.
- While open, re-place on resize and on scroll (capture phase, so scrolling a table shell counts).
## Behaviour
- Opens on hover AND on focus, tracked as two separate flags: moving the mouse away does not close a bubble the keyboard still holds open.
- Escape on the trigger dismisses it until the next hover or focus.
- Null or empty `content` never opens.
## API
- `content: ReactNode`, `children` (the trigger; must be focusable to be keyboard-reachable)
- `placement?: 'top' | 'bottom' | 'left' | 'right'` = `'top'`, `variant?: 'dark' | 'light'` = `'dark'`, `wide?` = false, `multiline?` = true, `className?` (on the wrapper)
- Named export `InfoTooltip` (`content`, `label` = "More information", `placement`, `variant`, `wide`, `className`, `iconClassName`): a tooltip (wrapper `align-middle`) around a button holding a 14px lucide `Info` icon, `rounded text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 focus-visible:ring-2 focus-visible:ring-indigo-400`. Its click calls `preventDefault` and `stopPropagation`, because these sit inside <label>s and click-to-edit rows.
## Accessibility
- Bubble `role="tooltip"` with a `useId` id. Clone the trigger element to add `aria-describedby={id}` only while the bubble is open. InfoTooltip's button gets `aria-label={label}`.
## Demo
Four "Hover" buttons placed top, bottom, left and right, then a row with: light ("Pale bubble, dark text"), wide ("Margin = (revenue − cost) / revenue × 100, computed per team and then averaged across the selection."), one line ("Never wraps"), and an InfoTooltip ("Shown behind an ⓘ beside a label.").
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { Info } from 'lucide-react';
import { cn } from '@/lib/cn';
import {
placeTooltip,
type TooltipCoords,
type TooltipPlacement,
} from '@/lib/tooltip-position';
/**
* The one tooltip primitive. Hint and helper copy lives in here rather than
* under the field it explains, so a form reads as a form.
*
* Three things drive the implementation:
*
* - It renders through a PORTAL with `position: fixed`. Half the anchors on this
* platform sit inside `overflow-x-auto custom-scrollbar` table shells, and
* CSS computes the other axis to `auto` as soon as one is not `visible` — so
* an absolutely-positioned bubble would be clipped by its own scroll box.
* A portal also puts it above the Drawer's `z-50` overlay without a z-index
* race.
* - It opens on hover AND on focus, and closes on Escape. Focus is what makes it
* keyboard-reachable; hover alone would hide the copy from anyone tabbing.
* Hover and focus are tracked separately so moving the mouse away does not
* close a bubble the keyboard is still holding open.
* - The trigger gets `aria-describedby` pointing at the bubble, which is where
* the text now lives for a screen reader too. That's the part that makes this
* a move rather than a deletion.
*
* `pointer-events-none` on the bubble is deliberate: a tooltip that can be
* hovered can trap the pointer and flicker against its own trigger.
*
* MERGED from two copies. This is bonus-adjustment's implementation — it is the
* one that flips on overflow, opens on focus, closes on Escape and wires
* `aria-describedby`, none of which the marketing-stats copy did. Three
* PRESENTATION props came across from that copy so its call sites can migrate:
* `variant`, `wide` and `multiline`.
*
* One default deliberately did NOT come across. marketing-stats defaulted to a
* single-line `whitespace-nowrap` bubble and treated wrapping as opt-in
* (`multiline`); this one wraps inside a max-width and always did. Wrapping
* stays the default, because a long hint in a nowrap bubble runs off the
* viewport. `multiline={false}` is there for the genuinely short labels that
* want one line.
*/
export type { TooltipPlacement };
export type TooltipVariant = 'dark' | 'light';
export default function Tooltip({
content,
children,
placement = 'top',
className,
variant = 'dark',
wide = false,
multiline = true,
}: {
/** The hint itself. Takes nodes, so copy carrying <code> survives the move. */
content: React.ReactNode;
/** The trigger. Must be focusable to be keyboard-reachable — see InfoTooltip. */
children: React.ReactNode;
placement?: TooltipPlacement;
/** Applied to the inline wrapper, not the bubble. */
className?: string;
/**
* 'light' is a pale bubble with dark text, for use ON a dark surface where the
* default near-black bubble disappears into its background.
*/
variant?: TooltipVariant;
/** Widens the wrap width from 16rem to 28rem, for longer help copy. */
wide?: boolean;
/**
* Wrapping. Defaults ON. Set false for a short label that should stay on one
* line — note this inverts marketing-stats' default, which was nowrap.
*/
multiline?: boolean;
}) {
const id = useId();
const anchorRef = useRef<HTMLSpanElement>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [coords, setCoords] = useState<TooltipCoords | null>(null);
const open = (hovered || focused) && !dismissed && content != null && content !== '';
const measure = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
setCoords(
placeTooltip(el.getBoundingClientRect(), placement, {
width: window.innerWidth,
height: window.innerHeight,
}),
);
}, [placement]);
useEffect(() => {
if (!open) {
setCoords(null);
return;
}
measure();
// Capture phase so a scroll inside a table shell repositions too, not just
// the window's own.
window.addEventListener('scroll', measure, true);
window.addEventListener('resize', measure);
return () => {
window.removeEventListener('scroll', measure, true);
window.removeEventListener('resize', measure);
};
}, [open, measure]);
const show = () => setDismissed(false);
const trigger = isValidElement(children)
? cloneElement(children as React.ReactElement<{ 'aria-describedby'?: string }>, {
'aria-describedby': open ? id : undefined,
})
: children;
return (
<>
<span
ref={anchorRef}
className={cn('inline-flex', className)}
onMouseEnter={() => {
show();
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onFocus={() => {
show();
setFocused(true);
}}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') setDismissed(true);
}}
>
{trigger}
</span>
{open &&
coords &&
createPortal(
<span
id={id}
role="tooltip"
style={{ top: coords.top, left: coords.left, transform: coords.transform }}
className={cn(
`fixed z-[200] pointer-events-none w-max rounded-lg text-[11px] leading-relaxed
px-2.5 py-1.5 shadow-lg text-left font-normal normal-case tracking-normal`,
multiline
? cn('whitespace-normal', wide ? 'max-w-[28rem]' : 'max-w-[16rem]')
: 'whitespace-nowrap',
variant === 'light'
? 'bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100'
: 'bg-slate-900 dark:bg-slate-700 text-white',
)}
>
{content}
</span>,
document.body,
)}
</>
);
}
/**
* The ⓘ affordance: the shape every migrated hint takes. Sits next to the label
* or control the copy used to sit under.
*
* `preventDefault` on click is load-bearing — several of these live inside a
* <label> or a click-to-edit row, where a bare button would toggle the checkbox
* or open a cell editor on the way past.
*/
export function InfoTooltip({
content,
label = 'More information',
placement = 'top',
className,
iconClassName,
variant,
wide,
}: {
content: React.ReactNode;
/** Accessible name for the trigger. Override where "more information" is vague. */
label?: string;
placement?: TooltipPlacement;
className?: string;
iconClassName?: string;
variant?: TooltipVariant;
/** Help copy behind an ⓘ is usually the long kind — see Tooltip's `wide`. */
wide?: boolean;
}) {
return (
<Tooltip
content={content}
placement={placement}
variant={variant}
wide={wide}
className={cn('align-middle', className)}
>
<button
type="button"
aria-label={label}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="inline-flex items-center justify-center rounded text-slate-400 hover:text-slate-600
dark:text-slate-500 dark:hover:text-slate-300 transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<Info className={cn('w-3.5 h-3.5', iconClassName)} aria-hidden />
</button>
</Tooltip>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
content* | React.ReactNode | — | The hint itself. Takes nodes, so copy carrying <code> survives the move. |
children* | React.ReactNode | — | The trigger. Must be focusable to be keyboard-reachable — see InfoTooltip. |
placement | TooltipPlacement | 'top' | |
className | string | — | Applied to the inline wrapper, not the bubble. |
variant | TooltipVariant | 'dark' | 'light' is a pale bubble with dark text, for use ON a dark surface where the default near-black bubble disappears into its background. |
wide | boolean | false | Widens the wrap width from 16rem to 28rem, for longer help copy. |
multiline | boolean | true | Wrapping. Defaults ON. Set false for a short label that should stay on one line — note this inverts marketing-stats' default, which was nowrap. |