All patterns

Hover Tooltip Fade

A hint fades up after a deliberate pause, so crossing a row of icons never sets off a flicker.

feedbacksubtleminimalinteraction · finite · intermediate · ~0.2s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

307 lines · react + motion only
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Hover Tooltip Fade
 *
 * Tooltips that stay quiet while the pointer is only passing through. A
 * deliberate delay before the first one opens means crossing a toolbar
 * costs nothing; once one is open the group stays warm, so moving along
 * the row swaps instantly instead of making you wait again at every
 * button. Keyboard focus never waits at all.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The panel sits on the CSS system colors, so it is opaque over content
 * on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `actions`, `openDelayMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TooltipAction = {
  /** What the tooltip says. */
  label: string;
  /** Optional shortcut printed beside the label. */
  keys?: string;
  /** Inline SVG path for the 16×16 icon. */
  path: string;
};

export type HoverTooltipFadeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The buttons in the group. */
  actions?: TooltipAction[];
  /** How long the pointer must rest before the first tooltip opens. */
  openDelayMs?: number;
  /** Grace after leaving, so a wobble across a gap doesn't close it. */
  closeDelayMs?: number;
  /** How long the group stays warm once a tooltip has been open. */
  warmMs?: number;
};

type VariantConfig = {
  /** px the panel rises through as it opens. */
  riseY: number;
  /** Fade in, kept a touch shorter than the travel. */
  inSeconds: number;
  /** Fade out. Always quicker than the way in. */
  outSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// A tooltip appears under the pointer and is read immediately, so it
// lands rather than settles: damping ratios (ζ = damping / 2√stiffness)
// stay at or above 0.92. Variants change travel and pace only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.05 — no overshoot at all. For a dense editing toolbar.
  subtle: {
    riseY: 2,
    inSeconds: 0.09,
    outSeconds: 0.06,
    spring: { type: "spring", stiffness: 610, damping: 52 },
  },
  // ζ ≈ 0.98. The all-purpose setting.
  default: {
    riseY: 6,
    inSeconds: 0.16,
    outSeconds: 0.1,
    spring: { type: "spring", stiffness: 420, damping: 40 },
  },
  // ζ ≈ 0.92, a little more travel — for a sparse toolbar where each
  // button is a considered choice.
  playful: {
    riseY: 13,
    inSeconds: 0.23,
    outSeconds: 0.14,
    spring: { type: "spring", stiffness: 290, damping: 31 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SAMPLE_ACTIONS: TooltipAction[] = [
  {
    label: "Comment",
    keys: "C",
    path: "M2.4 3.4h11.2v7.2H8.6L5.4 13.2v-2.6H2.4z",
  },
  {
    label: "Share link",
    keys: "S",
    path: "M6.6 9.4 9.4 6.6M6.2 4.4 7.6 3a2.6 2.6 0 0 1 3.7 3.7L9.9 8.1M6.1 7.9 4.7 9.3A2.6 2.6 0 0 0 8.4 13l1.4-1.4",
  },
  {
    label: "Version history",
    keys: "H",
    path: "M8 4.2v4l2.6 1.6M8 1.7a6.3 6.3 0 1 1-6.3 6.3",
  },
  {
    label: "Download a copy",
    keys: "D",
    path: "M8 2.4v7.2M8 9.6 5.2 6.8M8 9.6l2.8-2.8M3 13h10",
  },
];

export default function HoverTooltipFade({
  variant = "default",
  actions = SAMPLE_ACTIONS,
  openDelayMs = 420,
  closeDelayMs = 90,
  warmMs = 900,
}: HoverTooltipFadeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [active, setActive] = useState<number | null>(null);
  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const warmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Warmth is a ref, not state: it changes the behavior of the next
  // pointer event but must never re-render the row on its own.
  const warm = useRef(false);

  const clearAll = useCallback(() => {
    for (const timer of [openTimer, closeTimer, warmTimer]) {
      if (timer.current) clearTimeout(timer.current);
      timer.current = null;
    }
  }, []);

  useEffect(() => clearAll, [clearAll]);

  const open = (index: number) => {
    warm.current = true;
    setActive(index);
  };

  const enter = (index: number) => {
    if (closeTimer.current) clearTimeout(closeTimer.current);
    if (warmTimer.current) clearTimeout(warmTimer.current);
    if (openTimer.current) clearTimeout(openTimer.current);
    // Warm group: the wait was already paid at the first button, so
    // moving along the row must not charge for it again.
    if (warm.current) {
      open(index);
      return;
    }
    openTimer.current = setTimeout(() => open(index), openDelayMs);
  };

  const leave = () => {
    if (openTimer.current) clearTimeout(openTimer.current);
    if (closeTimer.current) clearTimeout(closeTimer.current);
    closeTimer.current = setTimeout(() => {
      setActive(null);
      warmTimer.current = setTimeout(() => {
        warm.current = false;
      }, warmMs);
    }, closeDelayMs);
  };

  return (
    <div
      onKeyDown={(event) => {
        if (event.key === "Escape") {
          clearAll();
          warm.current = false;
          setActive(null);
        }
      }}
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 4,
        padding: 5,
        borderRadius: 12,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
      }}
    >
      {actions.map((action, index) => {
        const isOpen = active === index;
        return (
          <span key={action.label} style={{ position: "relative", display: "inline-flex" }}>
            <button
              type="button"
              aria-label={action.label}
              // The panel is decoration over an accessible name that is
              // already on the button, so it is hidden from assistive tech
              // rather than duplicated into it.
              onPointerEnter={() => enter(index)}
              onPointerLeave={leave}
              onFocus={() => open(index)}
              onBlur={leave}
              style={{
                display: "inline-grid",
                placeItems: "center",
                width: 32,
                height: 32,
                borderRadius: 8,
                border: 0,
                background: isOpen ? tone(10) : "transparent",
                transition: "background-color 160ms ease-out",
                color: "inherit",
                cursor: "pointer",
              }}
            >
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
                <path
                  d={action.path}
                  stroke="currentColor"
                  strokeWidth="1.3"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  opacity="0.8"
                />
              </svg>
            </button>

            <AnimatePresence>
              {isOpen && (
                <motion.span
                  key="panel"
                  aria-hidden
                  // Reduced motion: the panel is placed rather than
                  // floated. The delay behavior is timing, not movement,
                  // so it is kept exactly as it is.
                  // x stays at -50% throughout: centring the panel on its
                  // button is a transform, and it has to live in the same
                  // animated transform as y rather than fighting it from
                  // the style object.
                  initial={
                    reduceMotion
                      ? { opacity: 0, x: "-50%" }
                      : { opacity: 0, x: "-50%", y: cfg.riseY }
                  }
                  animate={{ opacity: 1, x: "-50%", y: 0 }}
                  // Leaving is always quicker than arriving: a tooltip that
                  // lingers after the pointer has gone reads as lag.
                  exit={{
                    opacity: 0,
                    x: "-50%",
                    y: reduceMotion ? 0 : Math.round(cfg.riseY * 0.4),
                    transition: { duration: cfg.outSeconds, ease: "easeIn" },
                  }}
                  transition={{
                    y: cfg.spring,
                    opacity: {
                      duration: cfg.inSeconds,
                      ease: "easeOut",
                    },
                  }}
                  style={{
                    position: "absolute",
                    bottom: "calc(100% + 8px)",
                    left: "50%",
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    gap: 6,
                    width: "max-content",
                    maxWidth: 180,
                    padding: "5px 9px",
                    borderRadius: 8,
                    // A tooltip floats over content, so it needs opaque
                    // ground rather than a tinted one. `Canvas` and
                    // `CanvasText` are the CSS system colors for page
                    // background and page text: they follow the host app's
                    // light or dark surface with nothing to configure.
                    background: "Canvas",
                    color: "CanvasText",
                    border: `1px solid ${tone(14)}`,
                    boxShadow: "0 10px 26px rgba(0,0,0,0.16)",
                    fontSize: 11.5,
                    fontWeight: 550,
                    lineHeight: 1.3,
                    whiteSpace: "nowrap",
                    pointerEvents: "none",
                    zIndex: 2,
                  }}
                >
                  {action.label}
                  {action.keys && (
                    <span
                      style={{
                        padding: "1px 4px",
                        borderRadius: 4,
                        fontSize: 10,
                        fontWeight: 650,
                        background: tone(10),
                        opacity: 0.75,
                      }}
                    >
                      {action.keys}
                    </span>
                  )}
                </motion.span>
              )}
            </AnimatePresence>
          </span>
        );
      })}
    </div>
  );
}

About this pattern

Most of the craft in a hover hint is in the timing, not the fade. The pointer has to rest for a beat before the first one opens, which makes skimming a toolbar free; once one has opened the group stays warm for a moment, so moving along the row swaps instantly instead of charging the wait again at every button. Leaving has a short grace so a wobble across a gap does not close anything, and the way out is always quicker than the way in — a hint that lingers after the pointer has gone reads as lag. Keyboard focus opens with no delay at all, since a focused control was chosen deliberately. The panel sits on the CSS system colors, so it is genuinely opaque over content in either theme.

Icon toolbarUnlabelled controlsTruncated labelKeyboard shortcut hint

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Design canvas

    Toolbar hints hold back on a quick pass and then swap instantly along the row.

Related patterns