Skip to content
goodtabs UI
BrowseBrowse components

Tooltip set

Three tooltip patterns: icon label with shortcut, disabled reason and a two-line hint.

npx shadcn@latest add https://ui.goodtabs.co/r/tooltip-set.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/tooltip-set

Tip names an icon-only button and shows its shortcut as keycaps.

Disabled reason

Add a payment method to publish.

DisabledTip makes the wrapper focusable so the reason is reachable by keyboard.

Two-line hint

Every route is rendered with so first paint stays fast.

Hint attaches a title and a sentence to a term in running text.

Usage

import { Tip, DisabledTip, Hint } from "@/components/ui/tooltip-set"

<Tip label="Copy link" shortcut={["⌘", "C"]}>
  <Button size="icon" aria-label="Copy link">...</Button>
</Tip>

Props

PropTypeDefaultDescription
labelstringTip: the label, a few words.
shortcutstring[]Tip: keycaps shown after the label.
side"top" | "right" | "bottom" | "left""top"Preferred side on all three.
delaynumber300Tip: milliseconds before hover opens it.
reasonstringDisabledTip: why the control is disabled.
titlestringHint: the bold first line.
bodystringHint: one or two sentences.

Source

tooltip-set/tooltip-set.tsx
"use client";

import * as React from "react";
import { Kbd } from "@/components/ui/kbd";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

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

type TipProps = {
  /** The control the tip describes. Rendered with asChild, so it must forward a ref. */
  children: React.ReactElement;
  /** The label. Keep it to a few words. */
  label: string;
  /** Shortcut keys shown as keycaps after the label, for example ["⌘", "K"]. */
  shortcut?: string[];
  side?: Side;
  /** Milliseconds before it opens on hover. Keyboard focus opens it straight away. */
  delay?: number;
};

const KEY = "border-background/25 bg-background/10 text-background";

/**
 * A label tooltip for an icon-only control, with an optional shortcut. The tip repeats the
 * control's accessible name, so give the control its own aria-label as well.
 */
export function Tip({ children, label, shortcut, side = "top", delay = 300 }: TipProps) {
  return (
    <TooltipProvider delayDuration={delay}>
      <Tooltip>
        <TooltipTrigger asChild>{children}</TooltipTrigger>
        <TooltipContent side={side} sideOffset={6}>
          {label}
          {shortcut && (
            <span className="flex items-center gap-1" aria-hidden="true">
              {shortcut.map((k) => (
                <Kbd key={k} className={KEY}>
                  {k}
                </Kbd>
              ))}
            </span>
          )}
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

type DisabledTipProps = {
  /** The disabled control. */
  children: React.ReactNode;
  /** Why it is disabled, and what to do about it. */
  reason: string;
  side?: Side;
};

/**
 * Explains a disabled control. Disabled buttons cannot take focus or fire pointer events,
 * so the tip hangs on a focusable wrapper and the reason is wired up with aria-describedby.
 */
export function DisabledTip({ children, reason, side = "top" }: DisabledTipProps) {
  const id = React.useId();
  return (
    <TooltipProvider delayDuration={150}>
      <Tooltip>
        <TooltipTrigger asChild>
          <span
            tabIndex={0}
            aria-describedby={id}
            className="inline-flex rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>*]:pointer-events-none"
          >
            {children}
          </span>
        </TooltipTrigger>
        <TooltipContent side={side} sideOffset={6}>
          {reason}
        </TooltipContent>
      </Tooltip>
      <span id={id} className="sr-only">
        {reason}
      </span>
    </TooltipProvider>
  );
}

type HintProps = {
  /** The term or icon that needs a longer explanation. */
  children: React.ReactElement;
  title: string;
  body: string;
  side?: Side;
};

/**
 * A two-line tooltip: a title and a sentence. For a term in running text; the trigger should be a
 * real button or link so keyboard users can reach it.
 */
export function Hint({ children, title, body, side = "top" }: HintProps) {
  return (
    <TooltipProvider delayDuration={200}>
      <Tooltip>
        <TooltipTrigger asChild>{children}</TooltipTrigger>
        <TooltipContent side={side} sideOffset={6} className="block max-w-56 py-2 leading-snug">
          <span className="block font-medium">{title}</span>
          <span className="block text-background/75">{body}</span>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/tooltip-set/prompt.md, or the Copy AI prompt button above.

When to use

  • Naming an icon-only control
  • Explaining why a button is disabled
  • A short definition for a term in text

When not to use

  • Anything interactive or longer than two lines, use a hover card or popover
  • Information that touch users need, tooltips do not open on tap
  • Replacing a visible label

Agent notes

  • Client component; each export wraps its own TooltipProvider so it works without app-level setup
  • The tip never replaces an accessible name: give icon buttons an aria-label too
  • Keycaps are aria-hidden; put the shortcut on the control with aria-keyshortcuts if it matters
  • DisabledTip makes its wrapper tabIndex 0 and sets pointer-events none on the child so hover reaches the wrapper
  • Children of Tip and Hint must forward a ref and spread props, as Button and native elements do
  • Tooltip content is inverted (bg-foreground), which is the shipped primitive's style