Tooltip
Hover and focus tooltips with a spring scale-in, arrow, delay and keyboard shortcut hints.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-tooltip.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/motion/Tooltip.tsx
import { useRef, useState, type ReactNode } from 'react';
import { Bold, Copy, Download, Share2, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { usePresence } from './hooks';
type Side = 'top' | 'right' | 'bottom' | 'left';
const POS: Record<Side, string> = {
top: 'bottom-full left-1/2 mb-2.5 -translate-x-1/2 origin-bottom',
bottom: 'top-full left-1/2 mt-2.5 -translate-x-1/2 origin-top',
left: 'right-full top-1/2 mr-2.5 -translate-y-1/2 origin-right',
right: 'left-full top-1/2 ml-2.5 -translate-y-1/2 origin-left',
};
const HIDDEN: Record<Side, string> = {
top: 'translate-y-1',
bottom: '-translate-y-1',
left: 'translate-x-1',
right: '-translate-x-1',
};
const ARROW: Record<Side, string> = {
top: 'top-full left-1/2 -translate-x-1/2 -mt-1',
bottom: 'bottom-full left-1/2 -translate-x-1/2 -mb-1',
left: 'left-full top-1/2 -translate-y-1/2 -ml-1',
right: 'right-full top-1/2 -translate-y-1/2 -mr-1',
};
export type TooltipProps = {
content: ReactNode;
children: ReactNode;
side?: Side;
delay?: number;
shortcut?: string;
className?: string;
};
export function Tooltip({ content, children, side = 'top', delay = 250, shortcut, className }: TooltipProps) {
const [open, setOpen] = useState(false);
const timer = useRef<number>();
const { mounted, visible } = usePresence(open, 150);
const show = () => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(true), delay);
};
const hide = () => {
window.clearTimeout(timer.current);
setOpen(false);
};
return (
<span className="relative inline-flex" onPointerEnter={show} onPointerLeave={hide} onFocus={show} onBlur={hide}>
{children}
{mounted && (
<span
role="tooltip"
className={cn(
'pointer-events-none absolute z-50 flex items-center gap-2 whitespace-nowrap rounded-lg bg-ink-800 px-2.5 py-1.5 text-xs font-medium text-ink-50 shadow-pop',
POS[side],
visible
? 'scale-100 opacity-100 transition-[opacity,transform,translate] duration-300 ease-spring'
: cn('scale-90 opacity-0 transition-all duration-150 ease-out', HIDDEN[side]),
className,
)}
>
{content}
{shortcut && (
<kbd className="rounded bg-white/10 px-1.5 py-px font-mono text-[10px] font-medium text-ink-300">{shortcut}</kbd>
)}
<span className={cn('absolute h-2 w-2 rotate-45 bg-ink-800', ARROW[side])} />
</span>
)}
</span>
);
}
const btn =
'focus-ring flex h-10 w-10 items-center justify-center rounded-xl text-ink-300 transition-all hover:bg-white/5 hover:text-ink-50 active:scale-90';
export function TooltipDemo() {
return (
<div className="flex flex-col items-center gap-10">
<div className="flex items-center gap-1 rounded-2xl bg-ink-900 p-1.5 shadow-card">
<Tooltip content="Bold" shortcut="⌘B">
<button className={btn} aria-label="Bold"><Bold className="h-4 w-4" /></button>
</Tooltip>
<Tooltip content="Duplicate" shortcut="⌘D">
<button className={btn} aria-label="Duplicate"><Copy className="h-4 w-4" /></button>
</Tooltip>
<Tooltip content="Share link" shortcut="⌘L">
<button className={btn} aria-label="Share"><Share2 className="h-4 w-4" /></button>
</Tooltip>
<Tooltip content="Export as PDF">
<button className={btn} aria-label="Download"><Download className="h-4 w-4" /></button>
</Tooltip>
<div className="mx-1 h-5 w-px bg-white/10" />
<Tooltip content="Move to trash" shortcut="⌫">
<button className={cn(btn, 'hover:bg-rose-500/10 hover:text-rose-300')} aria-label="Delete"><Trash2 className="h-4 w-4" /></button>
</Tooltip>
</div>
<div className="grid grid-cols-4 gap-3">
{(['top', 'right', 'bottom', 'left'] as const).map((s) => (
<Tooltip key={s} content={`Tooltip on ${s}`} side={s} delay={100}>
<button className="focus-ring rounded-lg bg-ink-850 px-3 py-1.5 text-xs font-medium capitalize text-ink-200 shadow-hairline transition hover:bg-ink-800 hover:text-ink-50 active:scale-95">
{s}
</button>
</Tooltip>
))}
</div>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}