Built on @radix-ui/react-tooltip — a short informational hint anchored to a trigger. Opens on hover or keyboard focus, never traps focus or blocks scroll (it isn't a modal concept).
npx chandra-asri-ui add tooltipTooltip does not work without a TooltipProvider mounted somewhere above it in the tree — this bundle mounts it once in app/layout.tsx, so every Tooltip anywhere in the app just works with no per-page setup. Its skipDelayDuration (default 300ms) means moving the pointer from one tooltip trigger straight to another within that window skips the open delay on the second one.
Each button below forces its tooltip open on a different side — hover or Tab-focus one to see it, and Escape / moving away / losing focus all close it.
"use client";
import { buttonVariants } from "@/app/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/app/components/ui/tooltip";
import { cn } from "@/lib/utils";
const SIDES = [
{ side: "top", label: "Top" },
{ side: "right", label: "Right" },
{ side: "bottom", label: "Bottom" },
{ side: "left", label: "Left" },
] as const;
/**
* `TooltipProvider` is already mounted once in `app/layout.tsx`, so every
* `Tooltip` below just works with no extra wiring. Each button forces its
* tooltip open on a different `side` to show the slide direction flip —
* hover or Tab-focus any of them (keyboard focus opens it too, not just
* the mouse). `TooltipTrigger` renders a real `<button>` itself, so it's
* styled directly with `buttonVariants` instead of wrapping `Button` with
* `asChild` (this bundle's `Button` is a plain function component and
* can't accept the ref `asChild` needs).
*
* Each `Tooltip` sets `delayDuration={20}` — matching the source
* platform's own `TooltipComponent` helper (see tooltip.tsx) — instead of
* leaving it at Radix's stock 700ms. Without this, quickly scanning across
* several triggers in a row (exactly what this demo invites) means most
* hovers land under the delay and the tooltip never opens, which reads as
* "randomly doesn't work" rather than the expected/default behavior it
* actually is.
*/
export function TooltipDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-8 p-6">
{SIDES.map(({ side, label }) => (
<Tooltip key={side} delayDuration={20}>
<TooltipTrigger
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{label}
</TooltipTrigger>
<TooltipContent side={side}>
Opens from the {label.toLowerCase()}
</TooltipContent>
</Tooltip>
))}
</div>
);
}
TooltipComponent is a ready-to-use wrapper around the compose-manual pattern above, with delayDuration hardcoded to a near-instant 20ms (Radix defaults to 700ms). Its content prop is a plain string, not ReactNode — for anything richer than text, compose Tooltip / TooltipTrigger / TooltipContent directly instead, as the example above does.
<TooltipComponent content="Saved 2 minutes ago" label="Save status">
<InfoIcon className="h-4 w-4" />
</TooltipComponent>"use client";
import { cn } from "@/lib/utils";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
/**
* Built on @radix-ui/react-tooltip. `TooltipProvider` must wrap the app
* once at a high level (this bundle does it in `app/layout.tsx`) — without
* it, no `Tooltip` anywhere will open. Once it's mounted at the root, every
* `Tooltip` elsewhere just works, no per-usage provider needed.
*
* Opens on hover *or* keyboard focus (so it stays reachable without a
* mouse) and closes on mouse-leave, blur, or Escape — all bawaan Radix, not
* reimplemented here. Default open delay is Radix's own 700ms; the
* `TooltipComponent` helper below overrides that to a near-instant 20ms,
* matching the source platform's own usage.
*
* No `tailwindcss-animate` plugin here either (same story as
* Popover/Select/Dialog), so `TooltipContent` gets a plain CSS keyframe
* animation (`.animate-tooltip-content` in globals.css) driven by Radix's
* `data-state`/`data-side` attributes instead.
*/
export const TooltipProvider = TooltipPrimitive.Provider;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"animate-tooltip-content bg-dark-70 z-50 overflow-hidden rounded-md px-2 py-1 text-sm text-white",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
/**
* Ready-to-use helper: `content` is a plain string (not `ReactNode`), so it
* can't carry rich JSX — reach for the compose-manual pattern
* (`Tooltip`/`TooltipTrigger`/`TooltipContent`) directly when a tooltip
* needs more than text. `delayDuration` is hardcoded to 20ms here, matching
* the source platform; pass a custom delay by composing manually instead.
*/
export function TooltipComponent({
content,
children,
label,
}: {
content: string;
children: React.ReactNode;
label?: string;
}) {
return (
<Tooltip delayDuration={20}>
<TooltipTrigger aria-label={label}>{children}</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
);
}