Disclosure & OverlaysTooltip

A 10px mono tag on hover — Radix tooltip with earned delays.

Preview

The thinking

A tooltip confirms, it doesn't teach. It exists only to settle the one question an icon-only control leaves open — what does this do — so it has no right to appear until the cursor proves it's asking. That's the load-bearing decision here: every value is a gate, not a decoration. The 300ms delayDuration makes a pause the price of admission, and skipDelayDurationwaives it for a moving cursor already in the row, so the toll is paid by intent and never by accident. The detail that lifts it past a default is treating the label as machinery rather than prose — a 10px mono tag on --popover behind a hairline border — so the thing that answers a glance also reads as something built to be glanced at.

Interface design

Tooltip text is a tag, not prose: 10px mono, uppercase, the same sanctioned exception to the 14px floor that badges use. It earns the exception the same way — this is a label you glance at to confirm what an icon means, never a sentence you read. The chrome is cut to that size: px-1.5 py-px in a rounded-sm corner, with leading-[1.8] doing the vertical spacing so the padding barely has to. The surface is --popover with the hairline border, so even at tag size it reads as floating machinery, not page content.

Interaction

Hover is intent, not commitment — the 300ms delayDuration means a cursor passing through never summons anything, so the tooltip is earned by an actual pause. Once one is open, skipDelayDuration lets siblings open instantly for 150ms, so scanning a row of icons pays the toll exactly once. Both numbers are inference, not styling: the pause is the cursor asking a question, the skip window is a scan that already asked it. Keyboard focus skips the gate entirely — Radix opens in its instant-open state, which the class list animates with the same pop as delayed-open, because explicit focus needs no proof of intent. The pop grows from --radix-tooltip-content-transform-origin at a sideOffset of 6, entering at 200ms and leaving at 150ms. I make surfaces leave faster than they arrive.

Sound design

A tooltip makes no sound, and the silence is structural, not an oversight. The global SoundLayer plays its tap through one capturing click listener, only for elements that carry an interactive role, and never on hover — and a tooltip lives entirely on the hover side of that line. Nothing here needs data-silent because there is nothing to silence: the content carries no role the layer listens for, and opening one never involves a click. The only tap you'll hear is the trigger's own, when you stop reading the label and press the thing it labels.

Empathy

A screen reader never needs the hover at all: Radix mirrors the label into a visually hidden role="tooltip" span and points the trigger's aria-describedby at it, so the text rides along as a description of the control instead of a surface to go find. The demo goes one further and gives each icon button an sr-only name of its own, so the tooltip is confirmation, never the only copy of the truth. Keyboard users get in without a pointer — focus opens it instantly, blur closes it, and Escape dismisses it early. Under reduced motion, motion-reduce:animate-none drops the pop and the tag simply appears; the 300ms gate stays, because it's a courtesy, not an animation. What I haven't handled is long content, on purpose — a 10px uppercase tag has no business wrapping, so a label that overflows is a label I'd rewrite.
tooltip.tsx
"use client";

import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";

import { cn } from "@/lib/utils";

// 300ms to earn the first tooltip, 150ms grace to skip the wait on
// siblings — scanning a row of icons pays the toll once
const TooltipProvider = ({
	delayDuration = 300,
	skipDelayDuration = 150,
	...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
	<TooltipPrimitive.Provider
		delayDuration={delayDuration}
		skipDelayDuration={skipDelayDuration}
		{...props}
	/>
);
TooltipProvider.displayName = "TooltipProvider";

const Tooltip = TooltipPrimitive.Root;

const TooltipTrigger = TooltipPrimitive.Trigger;

// tooltip text is a tag, not prose — the badge's sanctioned 10px mono
// uppercase exception to the type floor
const TooltipContent = ({
	ref,
	className,
	sideOffset = 6,
	...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
	ref?: React.Ref<React.ComponentRef<typeof TooltipPrimitive.Content>>;
}) => (
	<TooltipPrimitive.Portal>
		<TooltipPrimitive.Content
			ref={ref}
			sideOffset={sideOffset}
			className={cn(
				"z-50 origin-[--radix-tooltip-content-transform-origin] rounded-sm border border-border bg-popover px-1.5 py-px font-mono text-[10px] uppercase leading-[1.8] text-popover-foreground data-[state=delayed-open]:animate-pop-in data-[state=instant-open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
				className
			)}
			{...props}
		/>
	</TooltipPrimitive.Portal>
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };