Transitions.dev

Home / Transitions

Tooltip open/close

Delayed in, travels, instant out

Open the live demo Install with the Skill

CSS

Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.

/* Transitions.dev — Tooltip open/close */

/*
  Usage:
    <span class="t-tt-group">
      <button class="t-tt-trigger" data-tooltip="Copy link" aria-describedby="tt-1">…</button>
      <button class="t-tt-trigger" data-tooltip="Share" aria-describedby="tt-1">…</button>
      <span class="t-tt" id="tt-1" role="tooltip" aria-hidden="true" data-show="false">
        <span class="t-tt-text"></span>
      </span>
    </span>

  One tooltip per group, shared by every trigger. The JS snippet
  writes the bubble's x + width for the hovered trigger: when it is
  already showing, the move tweens on --tt-move-dur so the bubble
  travels between triggers; when hidden, the geometry snaps and only
  the appear plays. The appear delay belongs to the show rule alone,
  so leaving the group hides it immediately. Trigger styling is
  yours; only the tooltip and its motion live here.
*/

:root {
  --tt-in-dur: 150ms;
  --tt-out-dur: 50ms;
  --tt-scale: 0.98;
  --tt-delay: 80ms;
  --tt-in-ease: ease-out;
  --tt-out-ease: ease-out;
  --tt-move-dur: 160ms;
  --tt-move-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tt-bg: #ffffff;
  --tt-fg: #2f2f2f;
}

.t-tt-group {
  position: relative;
  display: inline-flex;
}
.t-tt {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 0;                 /* the JS writes the measured width */
  overflow: hidden;
  /* Individual transform properties: the travel (translate) and the
     appear (scale) run on their own clocks. */
  translate: var(--tt-x, 0px) 0;
  scale: var(--tt-scale);
  transform-origin: 50% 100%;
  padding: 8px 12px;
  border-radius: 8px;
  background: var(--tt-bg);
  color: var(--tt-fg);
  white-space: nowrap;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.06),
    0 2px 6px 0 rgba(0, 0, 0, 0.05),
    0 4px 42px 0 rgba(0, 0, 0, 0.06);
  opacity: 0;
  pointer-events: none;
  /* Default rule controls the LEAVE state: no delay, so hiding plays
     immediately. The move lanes are always live. */
  transition:
    opacity   var(--tt-out-dur) var(--tt-out-ease),
    scale     var(--tt-out-dur) var(--tt-out-ease),
    translate var(--tt-move-dur) var(--tt-move-ease),
    width     var(--tt-move-dur) var(--tt-move-ease);
}
/* The appear delay belongs ONLY to the show rule, so hiding snaps it
   back to 0. A retarget while showing carries no delay at all. */
.t-tt[data-show="true"] {
  opacity: 1;
  scale: 1;
  transition:
    opacity   var(--tt-in-dur) var(--tt-in-ease) var(--tt-delay),
    scale     var(--tt-in-dur) var(--tt-in-ease) var(--tt-delay),
    translate var(--tt-move-dur) var(--tt-move-ease),
    width     var(--tt-move-dur) var(--tt-move-ease);
}
.t-tt-text { white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .t-tt { transition: none !important; }
}

React

Self-contained component; the styles inject themselves on first import.

// Transitions.dev — Tooltip open/close (React, self-contained)
// Drop into any React project — no extra CSS file needed.

// ── Styles ───────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
:root {
  --tt-in-dur: 150ms;
  --tt-out-dur: 50ms;
  --tt-scale: 0.98;
  --tt-delay: 80ms;
  --tt-in-ease: ease-out;
  --tt-out-ease: ease-out;
  --tt-move-dur: 160ms;
  --tt-move-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tt-bg: #ffffff;
  --tt-fg: #2f2f2f;
}

.t-tt-group {
  position: relative;
  display: inline-flex;
}
.t-tt {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 0;                 /* the JS writes the measured width */
  overflow: hidden;
  /* Individual transform properties: the travel (translate) and the
     appear (scale) run on their own clocks. */
  translate: var(--tt-x, 0px) 0;
  scale: var(--tt-scale);
  transform-origin: 50% 100%;
  padding: 8px 12px;
  border-radius: 8px;
  background: var(--tt-bg);
  color: var(--tt-fg);
  white-space: nowrap;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.06),
    0 2px 6px 0 rgba(0, 0, 0, 0.05),
    0 4px 42px 0 rgba(0, 0, 0, 0.06);
  opacity: 0;
  pointer-events: none;
  /* Default rule controls the LEAVE state: no delay, so hiding plays
     immediately. The move lanes are always live. */
  transition:
    opacity   var(--tt-out-dur) var(--tt-out-ease),
    scale     var(--tt-out-dur) var(--tt-out-ease),
    translate var(--tt-move-dur) var(--tt-move-ease),
    width     var(--tt-move-dur) var(--tt-move-ease);
}
/* The appear delay belongs ONLY to the show rule, so hiding snaps it
   back to 0. A retarget while showing carries no delay at all. */
.t-tt[data-show="true"] {
  opacity: 1;
  scale: 1;
  transition:
    opacity   var(--tt-in-dur) var(--tt-in-ease) var(--tt-delay),
    scale     var(--tt-in-dur) var(--tt-in-ease) var(--tt-delay),
    translate var(--tt-move-dur) var(--tt-move-ease),
    width     var(--tt-move-dur) var(--tt-move-ease);
}
.t-tt-text { white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .t-tt { transition: none !important; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p17")) {
  const __style = document.createElement("style");
  __style.id = "transitions-p17";
  __style.textContent = __TRANSITION_STYLES;
  document.head.appendChild(__style);
}

// Pair with the CSS from the CSS tab.
// One tooltip per group, shared by every trigger. Hovering a trigger
// writes the bubble's x + width: when it is already showing the move
// tweens (it travels); when hidden the geometry snaps and only the
// appear plays. Bring your own trigger styling.
import { useRef } from "react";

export function TooltipGroup({ id, items }) {
  // items: [{ label: "Copy", tooltip: "Copy link", onClick }]
  const group = useRef(null);
  const tip = useRef(null);
  const text = useRef(null);

  const hide = () => {
    tip.current.setAttribute("data-show", "false");
    tip.current.setAttribute("aria-hidden", "true");
  };

  const place = (trigger, label) => {
    const t = tip.current;
    const showing = t.getAttribute("data-show") === "true";
    text.current.textContent = label;
    const cs = getComputedStyle(t);
    const width = Math.ceil(
      text.current.scrollWidth +
        parseFloat(cs.paddingLeft) +
        parseFloat(cs.paddingRight)
    );
    const g = group.current.getBoundingClientRect();
    const r = trigger.getBoundingClientRect();
    const x = r.left - g.left + r.width / 2 - width / 2;
    if (!showing) {
      // Snap the geometry while hidden so only the appear plays.
      t.style.transition = "none";
      t.style.width = `${width}px`;
      t.style.setProperty("--tt-x", `${x}px`);
      void t.offsetWidth;
      t.style.transition = "";
    } else {
      t.style.width = `${width}px`;
      t.style.setProperty("--tt-x", `${x}px`);
    }
    t.setAttribute("data-show", "true");
    t.setAttribute("aria-hidden", "false");
  };

  return (
    <span ref={group} className="t-tt-group" onPointerLeave={hide}>
      {items.map((item) => (
        <button
          key={item.label}
          type="button"
          className="t-tt-trigger"
          aria-describedby={id}
          onPointerEnter={(e) => place(e.currentTarget, item.tooltip)}
          onFocus={(e) => place(e.currentTarget, item.tooltip)}
          onBlur={hide}
          onClick={item.onClick}
        >
          {item.label}
        </button>
      ))}
      <span
        ref={tip}
        id={id}
        className="t-tt"
        role="tooltip"
        aria-hidden="true"
        data-show="false"
      >
        <span ref={text} className="t-tt-text" />
      </span>
    </span>
  );
}