Skip to content

Tooltip

Delayed in, glides between triggers, instant out.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/glide-tooltip.json

Manual

  1. Install the dependencies

    npm install clsx motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/glide-tooltip/glide-tooltip.tsx
    "use client";
    
    import {
      createContext,
      useCallback,
      useContext,
      useEffect,
      useId,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
      type FocusEvent,
      type PointerEvent,
      type ReactElement,
      type ReactNode,
    } from "react";
    import { createPortal } from "react-dom";
    import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
    import { Slot } from "radix-ui";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    type Side = "top" | "bottom";
    
    interface Target {
      id: string;
      element: HTMLElement;
      content: ReactNode;
      side: Side;
    }
    
    interface TooltipGroup {
      activeId: string | null;
      tooltipId: string;
      show: (target: Target, immediate?: boolean) => void;
      hide: (id: string) => void;
    }
    
    const GroupContext = createContext<TooltipGroup | null>(null);
    
    export interface GlideTooltipProviderProps {
      /** Tooltips that share one floating surface, which glides between them. */
      children: ReactNode;
      /** Milliseconds of hover before the first tooltip appears. @default 500 */
      delay?: number;
      /** After a tooltip closes, others open instantly within this many milliseconds. @default 400 */
      skipDelay?: number;
    }
    
    const GAP = 8;
    const EDGE = 8;
    // Brief grace before closing, so moving between adjacent triggers glides instead of blinking.
    const GRACE = 70;
    const GLIDE = { stiffness: 520, damping: 40, mass: 0.6 };
    
    export function GlideTooltipProvider({ children, delay = 500, skipDelay = 400 }: GlideTooltipProviderProps) {
      const [target, setTarget] = useState<Target | null>(null);
      const visible = useRef<Target | null>(null);
      const openTimer = useRef<number | undefined>(undefined);
      const closeTimer = useRef<number | undefined>(undefined);
      const warmUntil = useRef(0);
      const wasHidden = useRef(true);
      const floatRef = useRef<HTMLDivElement>(null);
      const reduceMotion = useReducedMotion();
      const tooltipId = useId();
      const x = useSpring(0, GLIDE);
      const y = useSpring(0, GLIDE);
      const arrowX = useMotionValue(0);
    
      const commit = useCallback((next: Target | null) => {
        visible.current = next;
        setTarget(next);
      }, []);
    
      const show = useCallback(
        (next: Target, immediate = false) => {
          window.clearTimeout(openTimer.current);
          window.clearTimeout(closeTimer.current);
          if (visible.current || immediate || performance.now() < warmUntil.current) commit(next);
          else openTimer.current = window.setTimeout(() => commit(next), delay);
        },
        [commit, delay],
      );
    
      const hide = useCallback(
        (id: string) => {
          window.clearTimeout(openTimer.current);
          if (visible.current?.id !== id) return;
          window.clearTimeout(closeTimer.current);
          closeTimer.current = window.setTimeout(() => {
            commit(null);
            warmUntil.current = performance.now() + skipDelay;
          }, GRACE);
        },
        [commit, skipDelay],
      );
    
      // Place the surface above (or below, if there is no room) and keep it inside the viewport.
      const place = useCallback(() => {
        const current = visible.current;
        const surface = floatRef.current;
        if (!current || !surface) return;
        const rect = current.element.getBoundingClientRect();
        const width = surface.offsetWidth;
        const height = surface.offsetHeight;
        let side = current.side;
        if (side === "top" && rect.top - height - GAP < EDGE) side = "bottom";
        else if (side === "bottom" && rect.bottom + height + GAP > window.innerHeight - EDGE) side = "top";
        const left = Math.min(Math.max(rect.left + rect.width / 2 - width / 2, EDGE), window.innerWidth - width - EDGE);
        const top = side === "top" ? rect.top - height - GAP : rect.bottom + GAP;
        surface.dataset.side = side;
        arrowX.set(Math.min(Math.max(rect.left + rect.width / 2 - left, 10), width - 10));
        if (wasHidden.current || reduceMotion) {
          x.jump(left);
          y.jump(top);
        } else {
          x.set(left);
          y.set(top);
        }
        wasHidden.current = false;
      }, [arrowX, reduceMotion, x, y]);
    
      useLayoutEffect(() => {
        if (target) place();
        else wasHidden.current = true;
      }, [target, place]);
    
      useEffect(() => {
        if (!target) return;
        const onKey = (event: KeyboardEvent) => {
          if (event.key === "Escape") commit(null);
        };
        window.addEventListener("scroll", place, true);
        window.addEventListener("resize", place);
        window.addEventListener("keydown", onKey);
        return () => {
          window.removeEventListener("scroll", place, true);
          window.removeEventListener("resize", place);
          window.removeEventListener("keydown", onKey);
        };
      }, [target, place, commit]);
    
      useEffect(
        () => () => {
          window.clearTimeout(openTimer.current);
          window.clearTimeout(closeTimer.current);
        },
        [],
      );
    
      const group = useMemo(() => ({ activeId: target?.id ?? null, tooltipId, show, hide }), [target, tooltipId, show, hide]);
    
      return (
        <GroupContext.Provider value={group}>
          {children}
          {target
            ? createPortal(
                <motion.div
                  ref={floatRef}
                  id={tooltipId}
                  role="tooltip"
                  style={{ x, y }}
                  className="group/tip pointer-events-none fixed top-0 left-0 z-[60]"
                >
                  <AnimatePresence>
                    <motion.div
                      key="surface"
                      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, transition: { duration: duration.instant } }}
                      transition={{ duration: duration.quick, ease: ease.out }}
                      className="relative w-max max-w-64 origin-bottom rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-5 font-medium text-background shadow-lg group-data-[side=bottom]/tip:origin-top"
                    >
                      <motion.span
                        key={target.id}
                        initial={{ opacity: 0.4 }}
                        animate={{ opacity: 1 }}
                        transition={{ duration: duration.instant }}
                        className="flex items-center gap-1.5"
                      >
                        {target.content}
                      </motion.span>
                      {/* Position and rotation live on separate elements: CSS applies `rotate`
                          before `transform`, which would send the x offset along a 45° axis. */}
                      <motion.span
                        aria-hidden="true"
                        style={{ x: arrowX }}
                        className="absolute top-full left-0 -ml-1 size-2 -translate-y-1 group-data-[side=bottom]/tip:top-auto group-data-[side=bottom]/tip:bottom-full group-data-[side=bottom]/tip:translate-y-1"
                      >
                        <span className="block size-2 rotate-45 bg-foreground" />
                      </motion.span>
                    </motion.div>
                  </AnimatePresence>
                </motion.div>,
                document.body,
              )
            : null}
        </GroupContext.Provider>
      );
    }
    
    export interface GlideTooltipProps {
      /** Tooltip text or content. Keep it short; it isn't interactive. */
      content: ReactNode;
      /** Preferred side; flips automatically when there isn't room. @default "top" */
      side?: Side;
      /** Stops the tooltip from showing. @default false */
      disabled?: boolean;
      /** The trigger: a single focusable element, e.g. a button. */
      children: ReactElement;
    }
    
    function TooltipTrigger({ content, side = "top", disabled = false, children }: GlideTooltipProps) {
      const group = useContext(GroupContext)!;
      const id = useId();
      const ref = useRef<HTMLElement>(null);
      const active = group.activeId === id;
      const target = () => (ref.current ? { id, element: ref.current, content, side } : null);
    
      return (
        <Slot.Root
          ref={ref}
          aria-describedby={active ? group.tooltipId : undefined}
          onPointerEnter={(event: PointerEvent<HTMLElement>) => {
            const t = target();
            if (!disabled && t && event.pointerType === "mouse") group.show(t);
          }}
          onPointerLeave={() => group.hide(id)}
          onPointerDown={() => group.hide(id)}
          onFocus={(event: FocusEvent<HTMLElement>) => {
            const t = target();
            // Keyboard focus shows the tooltip right away; mouse clicks don't.
            if (!disabled && t && event.currentTarget.matches(":focus-visible")) group.show(t, true);
          }}
          onBlur={() => group.hide(id)}
        >
          {children}
        </Slot.Root>
      );
    }
    
    export function GlideTooltip(props: GlideTooltipProps) {
      const group = useContext(GroupContext);
      // Works on its own too: a lone tooltip gets a private group.
      if (!group) {
        return (
          <GlideTooltipProvider>
            <TooltipTrigger {...props} />
          </GlideTooltipProvider>
        );
      }
      return <TooltipTrigger {...props} />;
    }
    
    /** Utility for shortcut hints inside tooltip content. */
    export function TooltipKbd({ children, className }: { children: ReactNode; className?: string }) {
      return <kbd className={cn("font-sans text-[11px] tracking-wider text-background/60", className)}>{children}</kbd>;
    }

Usage

import { GlideTooltip, GlideTooltipProvider } from "@/components/ui/glide-tooltip/glide-tooltip";

export function Toolbar() {
  return (
    <GlideTooltipProvider>
      <GlideTooltip content="Bold">
        <button aria-label="Bold"><BoldIcon /></button>
      </GlideTooltip>
      <GlideTooltip content="Italic">
        <button aria-label="Italic"><ItalicIcon /></button>
      </GlideTooltip>
    </GlideTooltipProvider>
  );
}

API reference

GlideTooltipProvider

PropTypeDefaultDescription
children*ReactNode–Tooltips that share one floating surface, which glides between them.
delaynumber500Milliseconds of hover before the first tooltip appears.
skipDelaynumber400After a tooltip closes, others open instantly within this many milliseconds.

GlideTooltip

PropTypeDefaultDescription
content*ReactNode–Tooltip text or content. Keep it short; it isn't interactive.
sideSide"top"Preferred side; flips automatically when there isn't room.
disabledbooleanfalseStops the tooltip from showing.
children*ReactElement–The trigger: a single focusable element, e.g. a button.
  • All tooltips inside one GlideTooltipProvider share a single surface, which glides between triggers. After one closes, the next opens without the delay for a short warm window.
  • Collision-aware: the surface flips to the other side when there's no room and stays at least 8px inside the viewport, with the arrow tracking the trigger.
  • A lone GlideTooltip works without a provider.

Accessibility

  • The surface has role="tooltip", and the active trigger points to it with aria-describedby, so it's read as a description, not a name.
  • Keyboard focus shows the tooltip immediately; mouse hover waits for the delay; touch never triggers it, since tooltips don't work for touch.
  • Escape dismisses it, and pressing the trigger hides it so it never covers what you are clicking.
  • Tooltip content is never interactive. Give icon buttons an aria-label too; the tooltip only supplements it.
  • With reduced motion enabled, the surface jumps between triggers instead of gliding.