Feedback & Data
TooltipFloat
A label that hangs in the air above its target, bobbing a little and leaning toward the pointer. Shows on keyboard focus too, flips when there is no room, and Esc hides it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/tooltip-float.tsx
"use client";
import { cloneElement, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type PointerEvent, type ReactElement, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
type TooltipFloatProps = {
/** Tooltip text. */
content: ReactNode;
/** One focusable element, like a button or link. */
children: ReactElement<{ "aria-describedby"?: string }>;
/** Preferred side. Flips by itself when there is no room. */
side?: "top" | "bottom";
/** Milliseconds of hover before it shows. Keyboard focus shows it at once. */
delay?: number;
/** Replaces the default look. */
className?: string;
};
const GAP = 10;
const EDGE = 8;
const noop = () => () => {};
/* TooltipFloat: a label that hangs in the air above its target, bobbing a little and leaning toward the pointer. Works on keyboard focus too, Esc hides it, and the pointer can move onto it. */
export function TooltipFloat({
content,
children,
side = "top",
delay = 150,
className = "bg-[#241e44] text-white shadow-lg shadow-black/40",
}: TooltipFloatProps) {
const reduce = useReducedMotion();
const id = useId();
const anchor = useRef<HTMLSpanElement>(null);
const tip = useRef<HTMLDivElement>(null);
const timer = useRef<number>(undefined);
const [open, setOpen] = useState(false);
const browser = useSyncExternalStore(noop, () => true, () => false);
const lean = useMotionValue(0);
const x = useSpring(lean, { stiffness: 300, damping: 20 });
const toggle = (next: boolean, wait: number) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(next), wait);
};
const onMove = (e: PointerEvent) => {
const box = e.currentTarget.getBoundingClientRect();
lean.set(Math.max(-10, Math.min(10, (e.clientX - box.left - box.width / 2) * 0.15)));
};
// Position is measured before paint: flip when the side has no room, slide to stay on screen.
useLayoutEffect(() => {
const el = tip.current;
const box = anchor.current?.getBoundingClientRect();
if (!open || !el || !box) return;
const { offsetWidth: w, offsetHeight: h } = el;
const { clientWidth, clientHeight } = document.documentElement;
const fitsTop = box.top - h - GAP >= EDGE;
const fitsBottom = box.bottom + h + GAP <= clientHeight - EDGE;
const onTop = side === "top" ? fitsTop || !fitsBottom : fitsTop && !fitsBottom;
const middle = box.left + box.width / 2;
const left = Math.max(EDGE, Math.min(clientWidth - w - EDGE, middle - w / 2));
el.dataset.side = onTop ? "top" : "bottom";
el.style.top = `${onTop ? box.top - h - GAP : box.bottom + GAP}px`;
el.style.left = `${left}px`;
el.style.setProperty("--ax", `${middle - left}px`);
}, [open, side]);
// Esc or any scroll hides it.
useEffect(() => {
if (!open) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onScroll = () => setOpen(false);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
};
}, [open]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return (
<>
<span
ref={anchor}
className="inline-flex"
onPointerEnter={(e) => e.pointerType === "mouse" && toggle(true, delay)}
onPointerLeave={() => toggle(false, 120)}
onPointerMove={onMove}
onFocus={() => toggle(true, 0)}
onBlur={() => toggle(false, 0)}
>
{cloneElement(children, { "aria-describedby": id })}
</span>
<span id={id} role="tooltip" className="sr-only">
{content}
</span>
{browser &&
createPortal(
<AnimatePresence>
{open && (
<div
ref={tip}
aria-hidden
onPointerEnter={() => toggle(true, 0)}
onPointerLeave={() => toggle(false, 120)}
className="group fixed z-60 w-max max-w-[min(15rem,calc(100vw-1rem))]"
>
<motion.div
style={{ x: reduce ? 0 : x }}
initial={{ opacity: 0, scale: reduce ? 1 : 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.9 }}
transition={reduce ? { duration: 0.1 } : { type: "spring", stiffness: 520, damping: 26 }}
className="group-data-[side=bottom]:origin-[var(--ax)_0%] group-data-[side=top]:origin-[var(--ax)_100%]"
>
<motion.div
animate={reduce ? undefined : { y: [0, -2.5, 0] }}
transition={{ duration: 2.2, ease: "easeInOut", repeat: Infinity }}
className={`relative rounded-lg px-3 py-1.5 text-[13px] ${className}`}
>
{content}
<span
aria-hidden
style={{ left: "var(--ax)" }}
className="absolute h-2.5 w-2.5 -translate-x-1/2 rotate-45 bg-inherit group-data-[side=bottom]:-top-1 group-data-[side=top]:-bottom-1"
/>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/tooltip-float.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/tooltip-float-demo.tsx
"use client"; import { Bell, Share2, Trash2 } from "lucide-react"; import { TooltipFloat } from "@/components/ui/tooltip-float"; const BUTTON = "grid h-11 w-11 place-items-center rounded-xl border border-white/10 bg-white/5 text-white/80 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 [&_svg]:h-[18px] [&_svg]:w-[18px]"; export default function TooltipFloatExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="flex gap-3"> <TooltipFloat content="Alerts"> <button type="button" aria-label="Notifications" className={BUTTON}> <Bell /> </button> </TooltipFloat> <TooltipFloat content="Anyone with the link can view this page" side="bottom"> <button type="button" aria-label="Share" className={BUTTON}> <Share2 /> </button> </TooltipFloat> <TooltipFloat content="Moves to bin" className="bg-[#f87171] font-medium text-[#2a0f12]"> <button type="button" aria-label="Delete" className={BUTTON}> <Trash2 /> </button> </TooltipFloat> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| content | ReactNode | — | Tooltip text. |
| children | ReactElement | — | One focusable element, like a button or link. |
| side | "top" | "bottom" | "top" | Preferred side. Flips by itself when there is no room. |
| delay | number | 150 | Milliseconds of hover before it shows. |
| className | string | dark violet | Replaces the tooltip look. |