FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Tooltip

Tooltip

pnpm dlx shadcn@latest add …/r/tooltip.json

An accessible React tooltip for Next.js and Tailwind CSS: a short hint on hover and keyboard focus, fast across a toolbar, built on Radix Tooltip.

import { Button } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { Undo2Icon } from "lucide-react"

export function UndoButton() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <Button variant="ghost" size="icon" aria-label="Undo">
            <Undo2Icon />
          </Button>
        </TooltipTrigger>
        <TooltipContent>Undo</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  )
}
toolbar.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import {
  DownloadIcon,
  type LucideIcon,
  MessageSquarePlusIcon,
  Redo2Icon,
  Share2Icon,
  Undo2Icon,
} from "lucide-react";
import { Fragment, useId, useState } from "react";

type Side = "top" | "right" | "bottom" | "left";

const groups: { label: string; icon: LucideIcon }[][] = [
  [
    { label: "Undo", icon: Undo2Icon },
    { label: "Redo", icon: Redo2Icon },
  ],
  [
    { label: "Add a comment", icon: MessageSquarePlusIcon },
    { label: "Share", icon: Share2Icon },
    { label: "Download", icon: DownloadIcon },
  ],
];

/*
 * A toolbar of icon buttons, each with a tooltip, and the side in the top-right corner. Hover one, then move
 * along the row: the next tooltips appear at once. An empty row at the bottom, as tall as the setting's
 * row, keeps the toolbar centred.
 */
export default function TooltipToolbar() {
  const id = useId();
  const [side, setSide] = useState<Side>("top");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-side`} className="text-sm text-muted-foreground">
          Side
        </label>
        <Select value={side} onValueChange={(value) => setSide(value as Side)}>
          <SelectTrigger id={`${id}-side`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="top">Top</SelectItem>
            <SelectItem value="right">Right</SelectItem>
            <SelectItem value="bottom">Bottom</SelectItem>
            <SelectItem value="left">Left</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 items-center justify-center">
        {/* One provider for the row, so its tooltips share the delay and hand over to each other */}
        <TooltipProvider>
          <div className="flex items-center gap-1">
            {groups.map((group, index) => (
              <Fragment key={group[0]?.label}>
                {index > 0 ? <div aria-hidden className="mx-1 h-5 w-px bg-border" /> : null}
                {group.map(({ label, icon: Icon }) => (
                  <Tooltip key={label}>
                    <TooltipTrigger asChild>
                      <Button variant="ghost" size="icon" aria-label={label}>
                        <Icon aria-hidden />
                      </Button>
                    </TooltipTrigger>
                    <TooltipContent side={side}>{label}</TooltipContent>
                  </Tooltip>
                ))}
              </Fragment>
            ))}
          </div>
        </TooltipProvider>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Copy the source into your project.

    components/ui/tooltip.tsx
    "use client";
    
    import { cn } from "@/lib/utils";
    import { Tooltip as TooltipPrimitive } from "radix-ui";
    import { type ComponentProps, createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
    
    /*
     * Tooltip, built on Radix Tooltip: a short hint that shows on hover and on keyboard focus, and names nothing
     * itself (an icon button still needs its own aria-label; the tooltip describes it). Escape closes it.
     * - The first tooltip waits a moment, then opens out of its trigger through the blur (the popover-in
     *   keyframes, scaling from Radix's transform origin).
     * - Moving along a row of triggers (a toolbar), the next tooltips appear at once and take the last one's
     *   place: no delay and no animation, and the one before goes in the same frame instead of fading out
     *   under the new one. Keyboard focus works the same way. Leaving the row, the last tooltip closes back
     *   into its trigger; after `skipDelayDuration` the next one waits and animates again.
     * - The provider stays "warm" while a tooltip shows and for the skip window after, and its tooltips skip the
     *   delay meanwhile. Radix's own skip window starts when the previous tooltip hands over, so resting on a
     *   tooltip for longer than the window would bring the delay back for the next one.
     * - Nothing animates on first paint.
     * - A Tooltip outside a TooltipProvider gets its own, so one on its own just works. Wrap a toolbar (or the
     *   app) in one TooltipProvider so its tooltips share the delay and the hand-over.
     */
    
    /** The tooltips under one provider hand over to each other through this */
    type Group = {
      /** A tooltip opens: whether it takes the place of one that's showing or closed a moment ago */
      open: (tooltip: object) => boolean;
      /** A tooltip closes; `hide` makes it vanish at once if the next one takes its place */
      close: (tooltip: object, hide: () => void) => void;
    };
    
    const GroupContext = createContext<Group | null>(null);
    
    /** A tooltip is showing, or closed less than the skip window ago: the next one opens without the delay */
    const WarmContext = createContext(false);
    
    /** One tooltip's way of coming and going, read by its content */
    const TooltipContext = createContext<{ following: boolean; hidden: boolean } | null>(null);
    
    function TooltipProvider({
      delayDuration = 300,
      skipDelayDuration = 300,
      ...props
    }: ComponentProps<typeof TooltipPrimitive.Provider>) {
      const [warm, setWarm] = useState(false);
      const record = useRef({
        showing: null as object | null,
        closedAt: Number.NEGATIVE_INFINITY,
        hideLast: null as (() => void) | null,
        cooling: 0,
      });
    
      useEffect(() => () => window.clearTimeout(record.current.cooling), []);
    
      const group = useMemo<Group>(
        () => ({
          open: (tooltip) => {
            const last = record.current;
            const follows = last.showing !== null || performance.now() - last.closedAt < skipDelayDuration;
            // Radix closes the one before just ahead of this one opening; it goes in this frame
            if (follows) last.hideLast?.();
            last.showing = tooltip;
            window.clearTimeout(last.cooling);
            setWarm(true);
            return follows;
          },
          close: (tooltip, hide) => {
            const last = record.current;
            if (last.showing !== tooltip) return;
            last.showing = null;
            last.closedAt = performance.now();
            last.hideLast = hide;
            last.cooling = window.setTimeout(() => setWarm(false), skipDelayDuration);
          },
        }),
        [skipDelayDuration],
      );
    
      return (
        <GroupContext.Provider value={group}>
          <WarmContext.Provider value={warm}>
            <TooltipPrimitive.Provider
              data-slot="tooltip-provider"
              delayDuration={delayDuration}
              skipDelayDuration={skipDelayDuration}
              {...props}
            />
          </WarmContext.Provider>
        </GroupContext.Provider>
      );
    }
    
    type TooltipProps = ComponentProps<typeof TooltipPrimitive.Root>;
    
    function TooltipRoot({
      group,
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      delayDuration,
      ...props
    }: TooltipProps & { group: Group }) {
      const warm = useContext(WarmContext);
      // This tooltip's identity in the group
      const self = useRef({});
      const [uncontrolled, setUncontrolled] = useState(defaultOpen);
      // Open from the start counts as following: it's simply there, with nothing animating on first paint
      const [following, setFollowing] = useState(defaultOpen);
      const [hidden, setHidden] = useState(false);
      const open = openProp ?? uncontrolled;
    
      // A tooltip that unmounts while showing counts as closed, so the group doesn't stay warm
      useEffect(() => {
        const tooltip = self.current;
        return () => group.close(tooltip, () => {});
      }, [group]);
    
      return (
        <TooltipContext.Provider value={{ following, hidden }}>
          <TooltipPrimitive.Root
            data-slot="tooltip"
            open={open}
            onOpenChange={(next) => {
              // Radix's timers can report a state it's already in
              if (next === open) return;
              if (next) {
                setFollowing(group.open(self.current));
                setHidden(false);
              } else {
                group.close(self.current, () => setHidden(true));
              }
              if (openProp === undefined) setUncontrolled(next);
              onOpenChange?.(next);
            }}
            delayDuration={warm ? 0 : delayDuration}
            {...props}
          />
        </TooltipContext.Provider>
      );
    }
    
    function Tooltip(props: TooltipProps) {
      const group = useContext(GroupContext);
      if (group) return <TooltipRoot group={group} {...props} />;
      return (
        <TooltipProvider>
          <Tooltip {...props} />
        </TooltipProvider>
      );
    }
    
    function TooltipTrigger(props: ComponentProps<typeof TooltipPrimitive.Trigger>) {
      return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
    }
    
    function TooltipContent({
      className,
      sideOffset = 4,
      children,
      ...props
    }: ComponentProps<typeof TooltipPrimitive.Content>) {
      const tooltip = useContext(TooltipContext);
    
      return (
        <TooltipPrimitive.Portal>
          <TooltipPrimitive.Content
            data-slot="tooltip-content"
            data-following={tooltip?.following ? "" : undefined}
            sideOffset={sideOffset}
            className={cn(
              "z-(--z-tooltip) w-fit max-w-xs rounded-md bg-primary px-2 py-1 text-xs text-balance text-primary-foreground",
              "origin-(--radix-tooltip-content-transform-origin) data-[state=closed]:animate-popover-out",
              // Radix's open states are delayed-open and instant-open; one that follows another doesn't animate
              !tooltip?.following &&
                "data-[state=delayed-open]:animate-popover-in data-[state=instant-open]:animate-popover-in",
              // Handed over to the next tooltip: gone at once, while Radix waits out the closing keyframes
              tooltip?.hidden && "invisible",
              className,
            )}
            {...props}
          >
            {children}
          </TooltipPrimitive.Content>
        </TooltipPrimitive.Portal>
      );
    }
    
    export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
  4. 4

    Update the import paths to match your project.

API reference

TooltipProvider

Wrap a toolbar, or your whole app, so its tooltips share the delay: once one has opened, the next open at once. A Tooltip without a provider gets its own. Also accepts every Radix Tooltip Provider prop.

PropTypeDefaultDescription
delayDuration?number300Milliseconds the pointer rests on a trigger before its tooltip opens.
skipDelayDuration?number300Milliseconds after a tooltip closes during which the next one opens at once, without the delay or the opening animation.
disableHoverableContent?booleanfalseCloses the tooltip as soon as the pointer leaves the trigger, even towards the tooltip.

Tooltip

One trigger and its tooltip. Also accepts every Radix Tooltip Root prop.

PropTypeDefaultDescription
open?boolean–Whether it's open, when you control it.
defaultOpen?booleanfalseWhether it starts open.
onOpenChange?(open: boolean) => void–Called when it opens or closes.
delayDuration?number–Overrides the provider's delay for this tooltip.

TooltipTrigger

What the tooltip describes. It opens on hover and on keyboard focus, and closes on click and Escape. Use asChild with a Button; an icon-only Button still needs its own aria-label, since the tooltip only describes it.

PropTypeDefaultDescription
asChild?booleanfalseRender the single child (for example a Button) as the trigger.

TooltipContent

The hint: short text, inverted from the page. It opens out of its trigger through a soft blur. Also accepts every Radix Tooltip Content prop.

PropTypeDefaultDescription
side?"top" | "right" | "bottom" | "left""top"The side of the trigger it shows on. It flips when there's no room.
align?"start" | "center" | "end""center"How it lines up with the trigger along that side.
sideOffset?number4Gap between the trigger and the tooltip, in px.

Related components

  • Popover

    An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.

  • Reviewed by @maya, @theo and @ines.

    Hover Card

    An accessible React hover card for Next.js and Tailwind CSS: a preview card for a link, like a profile on an @mention, built on Radix Hover Card.

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.