Overlays & Feedbackv0.1.0
Tooltip
Delayed in, glides between triggers, instant out.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/glide-tooltip.jsonManual
Install the dependencies
npm install clsx motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | Tooltips that share one floating surface, which glides between them. |
delay | number | 500 | Milliseconds of hover before the first tooltip appears. |
skipDelay | number | 400 | After a tooltip closes, others open instantly within this many milliseconds. |
GlideTooltip
| Prop | Type | Default | Description |
|---|---|---|---|
content* | ReactNode | – | Tooltip text or content. Keep it short; it isn't interactive. |
side | Side | "top" | Preferred side; flips automatically when there isn't room. |
disabled | boolean | false | Stops 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.