Component
Tooltip
A short hint bubble that opens on hover or focus and skips the delay when opened in quick succession.
- #arc
- #component
- #tooltip
- #hint
- #overlay
- #hover
- #popup
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/tooltip.jsonFiles
components/arc/tooltip/tooltip.module.csscomponents/arc/tooltip/tooltip.tsx
npm packages
@radix-ui/react-tooltip@^1.4.1motion@^14.2.0
Registry items it pulls in
Source
View as Markdowntooltip.module.csstooltip.module.csstooltip.module.csscss
.tooltip { --tooltip-y: 3px; --tooltip-dot: 4px; --tooltip-dot-gap: 2px; position: relative; z-index: 90; max-width: 15rem; padding: var(--space-1) var(--space-2); border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }
.tooltip[data-side="bottom"] { --tooltip-y: -3px; }
/* A 4px dot marks the trigger, 2px off the bubble on the side that faces it. The 1px border is outside the padding box the offsets measure from. */
.tooltip::after { content: ""; position: absolute; width: var(--tooltip-dot); height: var(--tooltip-dot); border-radius: var(--radius-pill); background: var(--foreground); pointer-events: none; }
.tooltip[data-side="top"]::after { top: calc(100% + 1px + var(--tooltip-dot-gap)); left: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="bottom"]::after { bottom: calc(100% + 1px + var(--tooltip-dot-gap)); left: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="left"]::after { left: calc(100% + 1px + var(--tooltip-dot-gap)); top: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="right"]::after { right: calc(100% + 1px + var(--tooltip-dot-gap)); top: calc(50% - var(--tooltip-dot) / 2); }
/* The first tooltip waits, then rises a few pixels from its trigger. Within the skip window the next one only fades.
Transitions instead of keyframes, so returning to the trigger while it fades out reverses the fade instead of restarting it. */
@starting-style {
.tooltip[data-state$="-open"] { opacity: 0; transform: translateY(var(--tooltip-y)) scale(.97); }
.tooltip[data-instant][data-state$="-open"] { transform: none; }
}
.tooltip[data-instant][data-state$="-open"] { transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
.tooltip[data-state="closed"] { opacity: 0; transform: scale(.98); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: tooltip-exit var(--duration-instant) linear both; }
.text { position: relative; display: block; overflow: clip; overflow-clip-margin: var(--space-2); }
.measure { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(15rem - 2 * var(--space-2) - 2px); visibility: hidden; pointer-events: none; }
.line { display: block; width: max-content; max-width: calc(15rem - 2 * var(--space-2) - 2px); }
@keyframes tooltip-exit { to { --tooltip-exit: 1; } }
@media (prefers-reduced-motion: reduce) {
.tooltip, .tooltip[data-state="closed"], .tooltip[data-instant][data-state$="-open"] { transform: none; transition: opacity var(--duration-instant) var(--ease-standard); }
}
tooltip.tsxtooltip.tsxtooltip.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ReactElement, ReactNode } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./tooltip.module.css";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: "top" | "bottom" | "left" | "right";
}
const DELAY = 250;
const SKIP_WINDOW = 300;
/* Every Tooltip brings its own provider, so the skip window is shared here: while any tooltip is open, and briefly after the last one closes, the next opens without delay or travel. */
let warm = false;
let openCount = 0;
let coolTimer = 0;
const listeners = new Set<() => void>();
const warmth = {
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
get: () => warm,
set(next: boolean) { if (warm === next) return; warm = next; listeners.forEach(listener => listener()); },
opened() { openCount += 1; window.clearTimeout(coolTimer); warmth.set(true); },
closed() { openCount = Math.max(0, openCount - 1); if (openCount) return; window.clearTimeout(coolTimer); coolTimer = window.setTimeout(() => warmth.set(false), SKIP_WINDOW); },
};
/** String content crossfades when it changes while open, and the bubble springs to the new text size. */
function TooltipText({ text }: { text: string }) {
const reduced = useReducedMotion();
const measure = useRef<HTMLSpanElement>(null);
const measured = useRef<string | null>(null);
const [size, setSize] = useState<{ width: number; height: number; animate: boolean } | null>(null);
useLayoutEffect(() => {
const node = measure.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const box = entry.borderBoxSize?.[0];
const current = node.textContent;
const animate = measured.current !== null && measured.current !== current;
measured.current = current;
setSize({ width: Math.ceil(box?.inlineSize ?? node.offsetWidth), height: Math.ceil(box?.blockSize ?? node.offsetHeight), animate });
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return <motion.span className={styles.text} initial={false} animate={size ? { width: size.width, height: size.height } : undefined} transition={size?.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>
<span ref={measure} className={styles.measure} aria-hidden="true">{text}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} className={styles.line} initial={reduced ? false : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>
</AnimatePresence>
</motion.span>;
}
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
const isWarm = useSyncExternalStore(warmth.subscribe, warmth.get, () => false);
// Controlled so the instant flag lands in the same render that mounts the content (Radix reports uncontrolled changes a frame late).
const [open, setOpen] = useState(false);
const [instant, setInstant] = useState(false);
useEffect(() => {
if (!open) return;
warmth.opened();
return warmth.closed;
}, [open]);
return <TooltipPrimitive.Provider delayDuration={DELAY} skipDelayDuration={0}>
<TooltipPrimitive.Root open={open} delayDuration={isWarm ? 0 : DELAY} onOpenChange={next => { if (next) setInstant(warmth.get()); setOpen(next); }}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content className={styles.tooltip} data-instant={instant || undefined} side={side} sideOffset={12} collisionPadding={12}>
{typeof content === "string" || typeof content === "number" ? <TooltipText text={String(content)}/> : content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>;
}
export default Tooltip;
Used by
- Floating button groupComponent
A floating toolbar of action buttons with tooltips and optional shortcuts.