- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Tooltip
Compact visual hints with smooth Motion entrance and exit.
Playground
"use client";
import { Button } from "@/components/ui/button";
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
export const TooltipDemo = () => (
<TooltipProvider delay={0}>
<Tooltip defaultOpen={false} defaultTriggerId="tooltip-preview" disabled={false}>
<TooltipTrigger id="tooltip-preview" render={<Button variant="outline" />}>Save changes</TooltipTrigger>
<TooltipContent side="top" align="center" sideOffset={4} animated={true} showArrow={true}>
{"Save changes"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/tooltip.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react@^1.8.0
- cn
- motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn motion@^12.38.0Usage
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
<TooltipProvider>
<Tooltip>
<TooltipTrigger>Save changes</TooltipTrigger>
<TooltipContent>Save changes</TooltipContent>
</Tooltip>
</TooltipProvider>;Wrap the app or a related group in TooltipProvider. It shares hover timing so moving between nearby tooltips feels immediate after the first reveal. The provider defaults to zero hover delay; set delay={200} for a short pause. Without a provider, Base UI's trigger defaults to 600ms. A trigger-level delay or closeDelay overrides provider timing.
TooltipContent includes its portal, positioner, and optional arrow. Compose an existing button with TooltipTrigger render={<Button variant="outline" />}. This uses Base UI's render API, not Radix asChild. Install Button separately for the runnable examples.
Examples
Icon-only action
The button has its own accessible name. The tooltip is a supplementary visual label, never a replacement for aria-label.
"use client";
import { SaveIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const TooltipDemo = () => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger aria-label="Save changes" render={<Button variant="outline" size="icon"><SaveIcon aria-hidden="true" /></Button>} />
<TooltipContent>Save changes</TooltipContent>
</Tooltip>
</TooltipProvider>
);Placement and shared delay
Placement defaults to top/center with a 4px gap. Use side, align, sideOffset, and alignOffset to adjust it. Collision handling flips or shifts the tooltip near viewport edges with 8px clearance. The arrow and animation origin follow the resolved placement. Content wraps within 20rem and the available viewport width; keep hints short instead of using tooltips for long explanations.
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const TooltipSidesDemo = () => (
<TooltipProvider delay={200}>
<div className="flex flex-wrap justify-center gap-3">
{(["top", "right", "bottom", "left"] as const).map((side) => (
<Tooltip key={side}>
<TooltipTrigger render={<Button variant="outline">{side}</Button>} />
<TooltipContent side={side}>
Preferred placement: {side}
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
);Controlled visibility
Use open and onOpenChange when the parent owns visibility. The callback receives the requested state and Base UI event details; details.cancel() rejects a request. Pass the trigger's id as triggerId to anchor externally opened content. For initially open uncontrolled content, use defaultOpen and defaultTriggerId.
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const TooltipControlledDemo = () => {
const id = useId();
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-3">
<TooltipProvider delay={200}>
<Tooltip open={open} onOpenChange={setOpen} triggerId={id}>
<TooltipTrigger id={id} render={<Button variant="outline">Save changes</Button>} />
<TooltipContent>Save changes</TooltipContent>
</Tooltip>
</TooltipProvider>
<output className="text-xs text-muted-foreground">
{open ? "Open" : "Closed"}
</output>
</div>
);
};Motion
motion/react combines a fade, a subtle 0.98-to-1 scale, and a 4px movement away from the anchor: 220ms entrance with a gentle ease-out, and a shorter 160ms exit toward the anchor. Opacity settles slightly earlier so the label is readable while movement finishes. The movement and animation origin follow the collision-resolved placement, without bounce. The popup stays mounted until exit finishes; reopening interrupts dismissal from its current animated position without removing the active tooltip. Set animated={false} for immediate transitions. Reduced motion and Base UI's instant states (keyboard focus, adjacent-tooltip handoff, immediate dismissal) skip transitions. Set showArrow={false} for an arrowless label. Avoid CSS animation/transform utilities on the same popup because they compete with Motion.
Disabled actions
Tooltip.disabled and TooltipTrigger.disabled disable tooltip interaction, not the underlying button action. Native disabled buttons cannot receive keyboard focus; explain unavailable actions using visible helper text. A hover-only wrapper around a disabled button is not a reliable way to deliver important information to keyboard or touch users.
Consumer setup and optional stories
Requires React 19, Tailwind CSS 4, Base UI 1.8+, Motion 12.38+, and cn. Load your Tailwind stylesheet and shadcn semantic tokens, including foreground and background for the inverse tooltip surface. No website-only CSS or animation provider is needed. The new-york directory name does not imply Radix; this component adapts shadcn Base Nova.
Install Tooltip first. Stories are a separate optional item and do not reinstall or overwrite it:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/tooltip-stories.json
Use your existing @storybook/react setup with matching Storybook package versions (types/composition checked with 10.6.1). Discover **/*.stories.tsx in the configured UI directory and load your global Tailwind/theme stylesheet in the preview. Canvas and Controls require no additional addons; autodocs uses your existing @storybook/addon-docs setup. Stories include their own TooltipProvider and inherit your theme. This repository has no Storybook server; Storybook UI appearance is not verified here.
Both items follow the UI alias in components.json; the default destination is components/ui/tooltip.tsx. Installing the component can overwrite a customized tooltip there, so review the CLI diff first. The stories-only item installs only the colocated stories file.
Behavior & Accessibility
Mouse hover and keyboard focus reveal the hint. Escape, blur, and clicking the trigger dismiss it following Base UI behavior. Hovering the popup keeps it visible by default. The tooltip never takes focus and must not contain links, buttons, or inputs. Use Popover for interactive content or explanations whose trigger exists specifically to show the popup.
Base UI tooltips are supplementary visual hints, not an assistive-technology description mechanism. Provide a visible label or aria-label on the trigger independently. Touch input does not reveal tooltips. Essential instructions, validation messages, and unavailable-action reasons belong in inline text or an accessible popover, not only in this component.
Root and Provider inherit their Base UI Tooltip props. Trigger retains primitive semantics, native handlers, custom render elements, and public refs. Content forwards Popup props plus the positioning options listed below, not every Positioner prop. Generic payloads and detached handles remain supported through upstream Tooltip.createHandle() from @base-ui/react/tooltip.
Props
| Prop | Type / Description | Default |
|---|---|---|
| actionsRef | RefObject<Tooltip.Root.Actions | null>Tooltip: imperative close/unmount actions. The wrapper manages exit unmounting. | Not set |
| align | "start" | "center" | "end"TooltipContent: alignment relative to the trigger. | "center" |
| alignOffset | numberTooltipContent: offset along the alignment axis. | 0 |
| anchor | Positioner.Props['anchor']TooltipContent: optional alternate positioning anchor. | Trigger |
| animated | booleanTooltipContent: smooth Motion entrance and exit. Reduced motion and Base UI instant interactions skip transitions. | true |
| closeDelay | numberTooltipProvider or TooltipTrigger: hover closing delay in milliseconds. Trigger overrides Provider. | Trigger: 0 |
| closeOnClick | booleanTooltipTrigger: dismiss the tooltip when the trigger is clicked. | true |
| collisionAvoidance | Positioner.Props['collisionAvoidance']TooltipContent: inherited flip/shift policy. | Base UI default |
| collisionPadding | number | Partial<Record<Side, number>>TooltipContent: clearance from viewport boundaries. | 8 |
| content | ReactNode (children)TooltipContent children: a brief, non-interactive visual hint. This text control configures the demo only. | Not set |
| defaultOpen | booleanTooltip: initial uncontrolled visibility. This control remounts the preview. | false |
| defaultTriggerId | string | nullTooltip: trigger ID to anchor initially open content. | Not set |
| delay | numberTooltipProvider or TooltipTrigger: hover opening delay in milliseconds. Trigger overrides Provider; keyboard focus opens immediately. | Provider: 0; Trigger: 600 without a provider |
| disableHoverablePopup | booleanTooltip: prevent hovering the popup to keep it open. | false |
| disabled | booleanTooltip or TooltipTrigger: disables tooltip interaction, not the underlying button action. The demo uses Tooltip.disabled. | false |
| handle | TooltipHandle<Payload>Tooltip and TooltipTrigger: Base UI handle for detached triggers. Create using the upstream Tooltip.createHandle API. | Not set |
| onOpenChange | (open, details) => voidTooltip: requested visibility and Base UI reason. Call details.cancel() to reject a request. | Not set |
| onOpenChangeComplete | (open: boolean) => voidTooltip: notified after the opening/closing lifecycle completes. | Not set |
| open | booleanTooltip: controlled visibility. Pair with onOpenChange. | Not set |
| payload | PayloadTooltipTrigger: data passed to Tooltip function children for the active trigger. | Not set |
| render | ReactElement | ((props, state) => ReactElement)TooltipTrigger and TooltipContent: custom elements with composed primitive props, event handlers, and refs. | Not set |
| showArrow | booleanTooltipContent: show the positioning arrow. | true |
| side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"TooltipContent: preferred side. Collision handling may flip it. | "top" |
| sideOffset | numberTooltipContent: distance from the trigger in pixels. | 4 |
| timeout | numberTooltipProvider: time window in milliseconds for adjacent tooltips to open instantly. | 400 |
| trackCursorAxis | "none" | "x" | "y" | "both"Tooltip: cursor tracking along the chosen axis. | "none" |
| triggerId | string | nullTooltip: active trigger ID when controlling visibility, required to select among multiple triggers. | Not set |
Tooltip and TooltipProvider inherit Base UI root/provider props and do not render DOM nodes. TooltipTrigger inherits Base UI Trigger props. TooltipContent forwards Popup props and only the listed Positioner options, retaining custom render elements, refs, and event handlers. TooltipTrigger.disabled disables the tooltip, not the button action.
Source
"use client";
// Adapted from shadcn/ui's Base Nova Tooltip (MIT).
// https://ui.shadcn.com/docs/components/base/tooltip
/*
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { mergeProps } from "@base-ui/react/merge-props";
import { Tooltip as Primitive } from "@base-ui/react/tooltip";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useRef } from "react";
import type { ComponentProps, Ref } from "react";
const ActionsContext = createContext<(() => void) | null>(null);
export const TooltipProvider = ({
delay = 0,
...props
}: Primitive.Provider.Props) => <Primitive.Provider delay={delay} {...props} />;
export const Tooltip = <Payload,>({
actionsRef,
onOpenChange,
...props
}: Primitive.Root.Props<Payload>) => {
const internalActions = useRef<Primitive.Root.Actions | null>(null);
const actions = actionsRef ?? internalActions;
return (
<ActionsContext.Provider value={() => actions.current?.unmount()}>
<Primitive.Root
{...props}
actionsRef={actions}
onOpenChange={(open, details) => {
onOpenChange?.(open, details);
if (!open && !details.isCanceled) {
details.preventUnmountOnClose();
}
}}
/>
</ActionsContext.Provider>
);
};
export const TooltipTrigger = <Payload,>(
props: Primitive.Trigger.Props<Payload> & { ref?: Ref<HTMLElement> }
) => <Primitive.Trigger data-slot="tooltip-trigger" {...props} />;
const PopupElement = ({
nativeProps,
render,
state,
ref,
...props
}: ComponentProps<"div"> & {
nativeProps: ComponentProps<"div">;
render?: Primitive.Popup.Props["render"];
state: Primitive.Popup.State;
}) =>
useRender({
props: mergeProps(nativeProps, props),
ref: [nativeProps.ref ?? null, ref ?? null],
render,
state: { ...state },
});
const MotionPopupElement = motion.create(PopupElement);
const tooltipOffsets: Partial<
Record<Primitive.Popup.State["side"], { x: number; y: number }>
> = {
bottom: { x: 0, y: -4 },
left: { x: 4, y: 0 },
right: { x: -4, y: 0 },
top: { x: 0, y: 4 },
};
const visiblePose = { opacity: 1, scale: 1, x: 0, y: 0 };
const AnimatedPopup = ({
nativeProps,
render,
state,
animated,
}: {
nativeProps: ComponentProps<"div">;
render?: Primitive.Popup.Props["render"];
state: Primitive.Popup.State;
animated: boolean;
}) => {
const reduceMotion = useReducedMotion();
const unmount = useContext(ActionsContext);
const openRef = useRef(state.open);
openRef.current = state.open;
const animate = animated && !reduceMotion && !state.instant;
// Follow the collision-resolved side, not just the requested placement.
const offset = tooltipOffsets[state.side] ?? { x: 0, y: 0 };
const hiddenPose = {
opacity: 0,
scale: animate ? 0.98 : 1,
x: animate ? offset.x : 0,
y: animate ? offset.y : 0,
};
useEffect(() => {
if (!state.open && !animate) {
unmount?.();
}
}, [state.open, animate, unmount]);
return (
<MotionPopupElement
nativeProps={nativeProps}
render={render}
state={state}
initial={animate ? hiddenPose : visiblePose}
animate={state.open ? visiblePose : hiddenPose}
transition={{
duration: Number(animate) * (state.open ? 0.22 : 0.16),
ease: state.open ? [0.22, 1, 0.36, 1] : [0.4, 0, 1, 1],
opacity: { duration: Number(animate) * (state.open ? 0.18 : 0.14) },
}}
onAnimationComplete={() => {
if (!openRef.current) {
unmount?.();
}
}}
>
{nativeProps.children}
</MotionPopupElement>
);
};
export type TooltipContentProps = Primitive.Popup.Props &
Pick<
Primitive.Positioner.Props,
| "align"
| "alignOffset"
| "side"
| "sideOffset"
| "collisionPadding"
| "collisionAvoidance"
| "anchor"
> & { animated?: boolean; showArrow?: boolean };
export const TooltipContent = ({
align = "center",
alignOffset = 0,
side = "top",
sideOffset = 4,
collisionPadding = 8,
collisionAvoidance,
anchor,
animated = true,
showArrow = true,
className,
children,
render,
...props
}: TooltipContentProps) => (
<Primitive.Portal style={{ left: 0, position: "absolute", top: 0 }}>
<Primitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
collisionAvoidance={collisionAvoidance}
anchor={anchor}
className="isolate z-50"
>
<Primitive.Popup
data-slot="tooltip-content"
className={cn(
"relative w-max max-w-[min(20rem,var(--available-width),calc(100vw-1rem))] origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs leading-relaxed text-background wrap-break-word outline-none",
className
)}
{...props}
render={(nativeProps, state) => (
<AnimatedPopup
nativeProps={nativeProps}
render={render}
state={state}
animated={animated}
/>
)}
>
{children}
{showArrow && (
<Primitive.Arrow
data-slot="tooltip-arrow"
className="size-2 rotate-45 rounded-[2px] bg-foreground data-[side=top]:-bottom-1 data-[side=bottom]:-top-1 data-[side=left]:-right-1 data-[side=right]:-left-1"
/>
)}
</Primitive.Popup>
</Primitive.Positioner>
</Primitive.Portal>
);
Credits
- shadcn/ui: Adapted Base Nova Tooltip composition and styling, with Vandor Motion transitions and viewport constraints.