For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Tooltip

Compact visual hints with smooth Motion entrance and exit.

Playground

Live preview
Hover or Tab to the button. Click or Escape dismisses. Disable tooltip leaves the button usable. Initially open remounts the preview; Reset restores settings and closes it. Code reflects initial configuration, not transient visibility. Touch does not reveal tooltips.
Customize Tooltip
Customize
0.00
4.00
tooltip-demo.tsx
"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:

Dependencies3 required packages
  • @base-ui/react@^1.8.0
  • cn
  • motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn motion@^12.38.0

Usage

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.

tooltip-demo.tsx
"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.

tooltip-sides-demo.tsx
"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.

Closed
tooltip-controlled-demo.tsx
"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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
actionsRefRefObject<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"
alignOffsetnumber

TooltipContent: offset along the alignment axis.

0
anchorPositioner.Props['anchor']

TooltipContent: optional alternate positioning anchor.

Trigger
animatedboolean

TooltipContent: smooth Motion entrance and exit. Reduced motion and Base UI instant interactions skip transitions.

true
closeDelaynumber

TooltipProvider or TooltipTrigger: hover closing delay in milliseconds. Trigger overrides Provider.

Trigger: 0
closeOnClickboolean

TooltipTrigger: dismiss the tooltip when the trigger is clicked.

true
collisionAvoidancePositioner.Props['collisionAvoidance']

TooltipContent: inherited flip/shift policy.

Base UI default
collisionPaddingnumber | Partial<Record<Side, number>>

TooltipContent: clearance from viewport boundaries.

8
contentReactNode (children)

TooltipContent children: a brief, non-interactive visual hint. This text control configures the demo only.

Not set
defaultOpenboolean

Tooltip: initial uncontrolled visibility. This control remounts the preview.

false
defaultTriggerIdstring | null

Tooltip: trigger ID to anchor initially open content.

Not set
delaynumber

TooltipProvider or TooltipTrigger: hover opening delay in milliseconds. Trigger overrides Provider; keyboard focus opens immediately.

Provider: 0; Trigger: 600 without a provider
disableHoverablePopupboolean

Tooltip: prevent hovering the popup to keep it open.

false
disabledboolean

Tooltip or TooltipTrigger: disables tooltip interaction, not the underlying button action. The demo uses Tooltip.disabled.

false
handleTooltipHandle<Payload>

Tooltip and TooltipTrigger: Base UI handle for detached triggers. Create using the upstream Tooltip.createHandle API.

Not set
onOpenChange(open, details) => void

Tooltip: requested visibility and Base UI reason. Call details.cancel() to reject a request.

Not set
onOpenChangeComplete(open: boolean) => void

Tooltip: notified after the opening/closing lifecycle completes.

Not set
openboolean

Tooltip: controlled visibility. Pair with onOpenChange.

Not set
payloadPayload

TooltipTrigger: data passed to Tooltip function children for the active trigger.

Not set
renderReactElement | ((props, state) => ReactElement)

TooltipTrigger and TooltipContent: custom elements with composed primitive props, event handlers, and refs.

Not set
showArrowboolean

TooltipContent: show the positioning arrow.

true
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"

TooltipContent: preferred side. Collision handling may flip it.

"top"
sideOffsetnumber

TooltipContent: distance from the trigger in pixels.

4
timeoutnumber

TooltipProvider: time window in milliseconds for adjacent tooltips to open instantly.

400
trackCursorAxis"none" | "x" | "y" | "both"

Tooltip: cursor tracking along the chosen axis.

"none"
triggerIdstring | null

Tooltip: 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.