Tooltip
A short label revealed by hovering or focusing a control.
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")),
),
),
);
}Installation
npx jsrepo add @implementjs/ui/tooltip
jsrepo pulls button along with it.
Copy the file below to src/lib/components/ui/tooltip.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too.
import type { Child, ComponentProps } from "@implementjs/core";
import {
Tooltip as TooltipPrimitive,
TooltipContent as TooltipContentPrimitive,
TooltipPortal as TooltipPortalPrimitive,
TooltipProvider as TooltipProviderPrimitive,
TooltipTrigger as TooltipTriggerPrimitive,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type TooltipProviderProps = ComponentProps<typeof TooltipProviderPrimitive>;
export type TooltipProps = ComponentProps<typeof TooltipPrimitive>;
export type TooltipTriggerProps = ComponentProps<typeof TooltipTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type TooltipContentProps = ComponentProps<typeof TooltipContentPrimitive>;
export const TooltipPortal = TooltipPortalPrimitive;
export const TooltipProvider = createComponent(function TooltipProvider(
props: TooltipProviderProps,
...children: Child[]
) {
return TooltipProviderPrimitive(props, ...children);
});
export const Tooltip = createComponent(function Tooltip(props: TooltipProps, ...children: Child[]) {
return TooltipPrimitive(props, ...children);
});
export const TooltipTrigger = createComponent(function TooltipTrigger(
{
class: className,
variant = "default",
size = "default",
type = "button",
...props
}: TooltipTriggerProps,
...children: Child[]
) {
return TooltipTriggerPrimitive(
{
type,
...props,
"data-slot": "tooltip-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
export const TooltipContent = createComponent(function TooltipContent(
{ offset = 6, side = "top", align = "center", class: className, ...props }: TooltipContentProps,
...children: Child[]
) {
return TooltipContentPrimitive(
{
...props,
"data-slot": "tooltip-content",
offset,
side,
align,
class: cn(
"absolute z-50 w-fit rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground",
"origin-(--ip-tooltip-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"not-data-[state=closed]:block not-data-[state=closed]:translate-0 not-data-[state=closed]:scale-100 not-data-[state=closed]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:not-data-[state=closed]:opacity-0 starting:not-data-[state=closed]:scale-95",
"starting:not-data-[state=closed]:data-[side=bottom]:-translate-y-2 starting:not-data-[state=closed]:data-[side=top]:translate-y-2 starting:not-data-[state=closed]:data-[side=left]:translate-x-2 starting:not-data-[state=closed]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});Usage
A small inverted bubble above the trigger. Tooltips label — they do not hold content, and nothing inside one should be interactive; for that, use a popover.
TooltipTrigger renders through the button styles, so the thing being labelled is usually the trigger itself.
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/lib/components/ui/tooltip";
TooltipProvider(
Tooltip(TooltipTrigger({ variant: "outline" }, "Hover me"), TooltipContent("Add to library")),
);
The provider
TooltipProvider is what makes a group of tooltips feel like one: after the first opens, moving to a neighbour opens its tooltip immediately instead of waiting out the delay again. Wrap a toolbar — or the whole app — in one, and set delayDuration and skipDelayDuration there.
Placement
side, align, and offset are on the content, defaulting to top / center / 6. The bubble flips when it would leave the viewport and animates out of whichever side it settled on.
API Reference
Every prop the styling does not consume is forwarded to the Tooltip primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
delayDuration | number | 700 | How long the pointer must rest on a trigger before the tooltip opens, in milliseconds. |
skipDelayDuration | number | 300 | After a tooltip closes, how long another trigger opens instantly instead of waiting through the delay again, in milliseconds. |
disableHoverableContent | boolean | false | Close as soon as the pointer leaves the trigger instead of letting it travel to the content. |
disableCloseOnTriggerClick | boolean | false | Keep the tooltip open when the trigger is clicked. |
disabled | boolean | false | Disables every tooltip under the provider. |
ignoreNonKeyboardFocus | boolean | false | Only 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.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
delayDuration | number | — | Overrides the provider's delayDuration for this tooltip. |
disableHoverableContent | boolean | — | Overrides the provider's disableHoverableContent for this tooltip. |
disableCloseOnTriggerClick | boolean | — | Overrides the provider's disableCloseOnTriggerClick for this tooltip. |
disabled | boolean | — | Overrides the provider's disabled for this tooltip. |
ignoreNonKeyboardFocus | boolean | — | Overrides 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.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "default" | Which button style the part renders with. Also set as data-variant. |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "default" | The button's height and padding scale. Also set as data-size. |
disabled | Signal<boolean> | boolean | false | While true this trigger never opens the tooltip. |
| Data attribute | Value |
|---|---|
[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 tooltip bubble. Styled as a small inverted panel that fades and scales in from the side it opens on, and hides itself when closed. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
offset | number | 6 | Distance in pixels between the trigger and the bubble. |
| Data attribute | Value |
|---|---|
[data-tooltip-content] | Present |
[data-state] | "delayed-open" | "instant-open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
| CSS variable | Description |
|---|---|
--ip-tooltip-content-transform-origin | The transform origin of the content element. |
--ip-tooltip-content-available-width | The available width of the content element. |
--ip-tooltip-content-available-height | The available height of the content element. |
--ip-tooltip-anchor-width | The width of the anchor element. |
--ip-tooltip-anchor-height | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Readable<HTMLElement> | document.body | The element to mount into. Also available as chained .To(target). |
disabled | boolean | Readable<boolean> | false | Mount in place instead of teleporting. Also available as chained .Disabled(value). |