implement
Tooltip

Tooltip

A short label that appears when the pointer rests on a control or it takes keyboard focus.

import { Div } from "@implementjs/core";
import {
	Tooltip,
	TooltipContent,
	TooltipPortal,
	TooltipProvider,
	TooltipTrigger,
} from "@/lib/components/ui/tooltip";

export default function TooltipDemo() {
	return TooltipProvider(
		Div(
			{ class: "flex items-center gap-2" },
			Tooltip(
				TooltipTrigger({ variant: "outline" }, "Hover"),
				TooltipPortal(TooltipContent("Add to library")),
			),
			Tooltip(
				TooltipTrigger({ variant: "outline" }, "Or hover this"),
				TooltipPortal(TooltipContent("No delay the second time")),
			),
		),
	);
}

A tooltip labels a control when the pointer rests on it or it takes keyboard focus. Tooltip is the root, TooltipTrigger is the control, and TooltipContent is the bubble. Wrap the bubble in TooltipPortal when it needs to escape overflow, and wrap a group of tooltips in TooltipProvider so they share timing.

import {
	Tooltip,
	TooltipContent,
	TooltipPortal,
	TooltipProvider,
	TooltipTrigger,
} from "@implementjs/primitives";

TooltipProvider(Tooltip(TooltipTrigger("Hover"), TooltipPortal(TooltipContent("Add to library"))));

Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props on the trigger and the content are forwarded onto the underlying Button or Div.

A tooltip is a label, not a place to put content. It never opens on touch, nothing inside it is reachable with the keyboard, and it closes the moment the trigger is clicked, blurred, or scrolled away — so put nothing in it the user can't live without.

Provider and delays

Tooltips share timing through TooltipProvider: delayDuration (default 700) is how long the pointer must rest before the bubble opens, so sweeping across a toolbar doesn't flash a label for every button. Once one tooltip closes, skipDelayDuration (default 300) is the window in which the next trigger opens instantly instead of waiting again — that's what makes moving along a toolbar feel like one tooltip sliding between buttons.

TooltipProvider(
	{ delayDuration: 300, skipDelayDuration: 150 },
	Tooltip(TooltipTrigger("Bold"), TooltipContent("Bold")),
	Tooltip(TooltipTrigger("Italic"), TooltipContent("Italic")),
);

The provider also guarantees only one of its tooltips is open at a time. It is optional — a Tooltip without one behaves as if wrapped in a provider with the defaults — but without a shared provider each tooltip times itself, and the skip-delay handoff between them is lost.

Every provider prop except skipDelayDuration can be overridden per tooltip by passing the same prop to Tooltip.

The trigger and the content

TooltipTrigger renders a Button. Resting the pointer on it opens the bubble after the delay; keyboard focus opens it immediately, with no delay. Clicking the trigger closes it (pass disableCloseOnTriggerClick to keep it up), as do blur, Escape, and scrolling the trigger's container. Pass ignoreNonKeyboardFocus when a mouse press on the trigger shouldn't pop the bubble open under the cursor.

TooltipContent is a Div with role="tooltip", and while open the trigger points at it with aria-describedby — so the bubble's text is the trigger's accessible description. Keep it to text.

By default the bubble is hoverable: the pointer can travel from the trigger onto the bubble without it closing, along a safe polygon between the two. Pass disableHoverableContent to close as soon as the pointer leaves the trigger instead.

Open state

Tooltip owns whether the bubble is open. Hover and focus drive it, but you can seed it with a boolean or hand it a signal to read and write from outside (signal() returns a writable unchanged, so the same prop accepts both):

const open = signal(false);

Tooltip({ open }, TooltipTrigger("Hover"), TooltipContent("Hello"));

Button({ onClick: () => open.set(false) }, "Close");

disabled turns a tooltip off — on the provider for all of them, or per tooltip or per trigger:

Tooltip({ disabled: true }, TooltipTrigger("Hover"), TooltipContent("Never opens."));

Portal

TooltipPortal is the Portal helper under a tooltip name. It renders its children into document.body by default so the bubble is not clipped by overflow or trapped in a parent stacking context. Context still resolves from where you declared it.

TooltipPortal(TooltipContent("Hello"));

TooltipPortal({ to: overlayRoot }, TooltipContent("Hello"));

Styling

Trigger and content expose data-state as "delayed-open", "instant-open", or "closed""delayed-open" when the bubble opened by waiting through the hover delay, "instant-open" when it skipped it (keyboard focus, or another tooltip just closed). Animate the two differently, or treat anything that isn't "closed" as open. Content also sets data-side ("top", "bottom", "left", "right") and data-align, so motion can grow out of the trigger.

Positioning writes CSS variables on the content: --ip-tooltip-content-transform-origin for origin-aware scale, --ip-tooltip-anchor-width / --ip-tooltip-anchor-height to match the trigger, and --ip-tooltip-content-available-width / --ip-tooltip-content-available-height to stay inside the viewport.

TooltipTrigger({ class: "rounded-md border px-3 py-2 text-sm" }, "Hover");

TooltipContent(
	{
		class:
			"absolute z-50 w-fit origin-(--ip-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-[opacity,translate,scale,display] transition-discrete data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closed]:data-[side=top]:translate-y-2 starting:not-data-[state=closed]:scale-95 starting:not-data-[state=closed]:opacity-0",
	},
	"Add to library",
);

data-state is there for visibility and open versus closed. data-side is the actual placed side (after flip), so enter and exit stay pointed at the trigger.

API Reference

TooltipProvider

Shares timing across every tooltip inside it: one open at a time, and moving between triggers within skipDelayDuration skips the open delay. Optional — a Tooltip without one uses these defaults.

PropTypeDefaultDescription
delayDurationnumber700How long the pointer must rest on a trigger before the tooltip opens, in milliseconds.
skipDelayDurationnumber300After a tooltip closes, how long another trigger opens instantly instead of waiting through the delay again, in milliseconds.
disableHoverableContentbooleanfalseClose as soon as the pointer leaves the trigger instead of letting it travel to the content.
disableCloseOnTriggerClickbooleanfalseKeep the tooltip open when the trigger is clicked.
disabledbooleanfalseDisables every tooltip under the provider.
ignoreNonKeyboardFocusbooleanfalseOnly open on focus when the focus is keyboard-driven (matches :focus-visible).

Tooltip

The root. Owns whether the tooltip is open and provides that to the parts inside it. The timing props default to the provider's values.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
delayDurationnumberOverrides the provider's delayDuration for this tooltip.
disableHoverableContentbooleanOverrides the provider's disableHoverableContent for this tooltip.
disableCloseOnTriggerClickbooleanOverrides the provider's disableCloseOnTriggerClick for this tooltip.
disabledbooleanOverrides the provider's disabled for this tooltip.
ignoreNonKeyboardFocusbooleanOverrides the provider's ignoreNonKeyboardFocus for this tooltip.

TooltipTrigger

Opens the tooltip on hover after the delay, or instantly on keyboard focus. Clicking closes it. While open it points at the content with aria-describedby. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
disabledSignal<boolean> | booleanfalseWhile true this trigger never opens the tooltip.
Data attributeValue
[data-tooltip-trigger]Present
[data-state]"delayed-open" | "instant-open" | "closed"
[data-disabled]Present when disabled
[data-delay-duration]The resolved open delay in milliseconds

TooltipContent

The bubble. Sets role="tooltip". Style it against data-state and data-side; the primitive does not hide it for you. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""top"Preferred side of the trigger to place the bubble.
align"start" | "center" | "end""center"How the bubble aligns along the chosen side.
offsetnumber0Distance in pixels between the trigger and the bubble.
Data attributeValue
[data-tooltip-content]Present
[data-state]"delayed-open" | "instant-open" | "closed"
[data-side]"top" | "bottom" | "left" | "right"
[data-align]"start" | "center" | "end"
CSS variableDescription
--ip-tooltip-content-transform-originThe transform origin of the content element.
--ip-tooltip-content-available-widthThe available width of the content element.
--ip-tooltip-content-available-heightThe available height of the content element.
--ip-tooltip-anchor-widthThe width of the anchor element.
--ip-tooltip-anchor-heightThe height of the anchor element.

TooltipPortal

Renders its children into another DOM parent so the bubble escapes overflow and stacking. This is the core Portal helper; context still resolves from where the portal is declared.

PropTypeDefaultDescription
toHTMLElement | Readable<HTMLElement>document.bodyThe element to mount into. Also available as chained .To(target).
disabledboolean | Readable<boolean>falseMount in place instead of teleporting. Also available as chained .Disabled(value).