Core Component
Tooltip
Contextual hint that appears on hover or focus with a skip-delay pattern — instant on subsequent triggers.
Preview
Send message
More options
Previous
Next
Source
Full component implementation using the design system tokens.
tsx
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
let lastCloseTime = 0;
export function DSTooltip({
content,
children,
side = "top",
delayMs = 200,
}: {
content: string;
children: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
delayMs?: number;
}) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const skipAnimation = useRef(false);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const rect = trigger.getBoundingClientRect();
const tipRect = tooltip.getBoundingClientRect();
const gap = 8;
let x = 0, y = 0;
switch (side) {
case "top": x = rect.left + rect.width / 2 - tipRect.width / 2; y = rect.top - tipRect.height - gap; break;
case "bottom": x = rect.left + rect.width / 2 - tipRect.width / 2; y = rect.bottom + gap; break;
case "left": x = rect.left - tipRect.width - gap; y = rect.top + rect.height / 2 - tipRect.height / 2; break;
case "right": x = rect.right + gap; y = rect.top + rect.height / 2 - tipRect.height / 2; break;
}
setPosition({ x, y });
}, [side]);
const show = useCallback(() => {
clearTimeout(timeoutRef.current);
const timeSinceClose = Date.now() - lastCloseTime;
skipAnimation.current = timeSinceClose < 300;
timeoutRef.current = setTimeout(() => setVisible(true), timeSinceClose < 300 ? 0 : delayMs);
}, [delayMs]);
const hide = useCallback(() => {
clearTimeout(timeoutRef.current);
setVisible(false);
lastCloseTime = Date.now();
}, []);
useEffect(() => { if (visible) updatePosition(); }, [visible, updatePosition]);
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const tooltipId = useRef(`tooltip-${Math.random().toString(36).slice(2, 9)}`).current;
return (
<>
<div ref={triggerRef} onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}
aria-describedby={visible ? tooltipId : undefined} className="inline-block">
{children}
</div>
<div ref={tooltipRef} id={tooltipId} role="tooltip" className="fixed z-[60] pointer-events-none"
style={{ left: position.x, top: position.y, opacity: visible ? 1 : 0,
transform: visible ? "scale(1)" : "scale(0.97)",
transition: skipAnimation.current ? "none" : "opacity 125ms var(--ease-out), transform 125ms var(--ease-out)" }}>
<div className="px-3 py-1.5 text-xs font-medium text-background bg-foreground rounded-lg shadow-lg whitespace-nowrap">
{content}
</div>
</div>
</>
);
}Props
All available props with types and defaults.
| Prop | Type | Default | Description |
|---|---|---|---|
content* | string | — | Tooltip text |
children* | ReactNode | — | Trigger element |
side | 'top' | 'bottom' | 'left' | 'right' | 'top' | Preferred tooltip position |
delayMs | number | 200 | Initial show delay in ms |
Variants
Top (default)
Tooltip above the trigger element.
Send message
tsx
<DSTooltip content="Send message">
<DSButton>Send</DSButton>
</DSTooltip>Bottom
Tooltip below the trigger element.
Send message
tsx
<DSTooltip content="More options" side="bottom">
<DSButton variant="ghost">⋯</DSButton>
</DSTooltip>Prompt Guide
Prompt Guide — Tooltip
Use for
- Icon-only buttons that need labels
- Truncated text that needs full display
- Keyboard shortcut hints
- Brief parameter descriptions
Don't use for
- Interactive content — use a popover instead
- Error messages — use inline validation
- Long descriptions — keep to one line
AI Context
Implements the skip-delay pattern: first tooltip shows after 200ms, subsequent tooltips appear instantly if triggered within 300ms of the last close. This matches the macOS/Vercel tooltip UX. Animation is 125ms scale(0.97→1) + opacity.