v1.0

Tooltip

Preview

Placement

Loading…

Preview

Appearance

Loading…

Preview

InfoTooltip

Loading…

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

PropTypeDefaultDescription
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.
placementTooltipPlacement'top'
classNamestring—Applied to the inline wrapper, not the bubble.
variantTooltipVariant'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.
widebooleanfalseWidens the wrap width from 16rem to 28rem, for longer help copy.
multilinebooleantrueWrapping. Defaults ON. Set false for a short label that should stay on one line — note this inverts marketing-stats' default, which was nowrap.