Tooltip

Names a control whose purpose is not obvious from its face, most often an IconButton.

Feedback Tooltip.jsx Tooltip.d.ts Tooltip.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Tooltip is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Tooltip } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Names a control whose purpose is not obvious from its face, most often an IconButton.

Rules

  • Tooltips are supplementary, never the only source of a label. An IconButton still needs its own label prop; the tooltip repeats it for sighted users.
  • Never put an action, a link, or anything the user must read inside a tooltip. It is unreachable on touch and invisible to keyboard users who do not focus the trigger.
  • The trigger must be focusable. A tooltip on a plain span never opens for keyboard users.
  • A few words. Anything longer is a Popover.

Tradeoffs

Tooltips do not exist on touch devices. Any information you put in one is information a phone user will not get, so plan the mobile affordance separately.

Props

The typed contract, from Tooltip.d.ts.

import * as React from "react";

/** Short label revealed on hover or focus. */
export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "content"> {
  /** Tooltip text. Keep it to a few words. */
  content: React.ReactNode;
  /** The trigger. Must be focusable. */
  children: React.ReactNode;
  /** @default "top" */
  placement?: "top" | "bottom" | "left" | "right";
  /** Hover delay in ms. @default 200 */
  delay?: number;
}

export declare function Tooltip(props: TooltipProps): React.JSX.Element;

Tokens it reads

Every custom property Tooltip.jsx resolves, read from the source: 9 semantic. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-elevation-2semanticvar(--dt-shadow-raw-2)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-surface-inversesemanticvar(--dt-color-neutral-900)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-inversesemanticvar(--dt-color-neutral-050)

Source

React only, no dependencies, every value a token.

import React from "react";

export function Tooltip({ content, children, placement = "top", delay = 200, style, ...rest }) {
  const [open, setOpen] = React.useState(false);
  const timer = React.useRef(null);
  const id = React.useId();
  const show = () => { clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(true), delay); };
  const hide = () => { clearTimeout(timer.current); setOpen(false); };
  React.useEffect(() => () => clearTimeout(timer.current), []);
  React.useEffect(() => {
    if (!open) return;
    const onKey = e => e.key === "Escape" && setOpen(false);
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);
  const pos = {
    top: { bottom: "calc(100% + 6px)", left: "50%", transform: "translateX(-50%)" },
    bottom: { top: "calc(100% + 6px)", left: "50%", transform: "translateX(-50%)" },
    left: { right: "calc(100% + 6px)", top: "50%", transform: "translateY(-50%)" },
    right: { left: "calc(100% + 6px)", top: "50%", transform: "translateY(-50%)" },
  }[placement];
  return (
    <span style={{ position: "relative", display: "inline-flex", ...style }} onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} {...rest}>
      {React.isValidElement(children) ? React.cloneElement(children, { "aria-describedby": open ? id : undefined }) : children}
      {open && (
        <span role="tooltip" id={id} style={{
          position: "absolute", ...pos, zIndex: 40, pointerEvents: "none",
          background: "var(--dt-surface-inverse)", color: "var(--dt-text-inverse)",
          padding: "var(--dt-space-inset-2xs, 4px) var(--dt-space-inset-xs)",
          borderRadius: "var(--dt-radius-control)", boxShadow: "var(--dt-elevation-2)",
          fontFamily: "var(--dt-text-body-xs-family)", fontSize: "var(--dt-text-body-xs-size)",
          lineHeight: "var(--dt-text-body-xs-line)", whiteSpace: "nowrap", maxWidth: 260,
        }}>{content}</span>
      )}
    </span>
  );
}

In context

Tooltip also appears on the feedback reference cards, alongside the rest of its family.