# Tooltip

> Hover and focus hints on four sides.

Category: Overlay. Docs: https://get-neonkit.vercel.app/components/tooltip

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/tooltip.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/tooltip`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Tooltip } from "@/components/ui/tooltip"

<Tooltip content="Bold ⌘B">
  <Button size="icon" aria-label="Bold"><Bold /></Button>
</Tooltip>
```

## Examples

### Toolbar

After the first tooltip opens, moving to a neighbour shows its tooltip instantly.

```tsx
import { Bold, Code, Italic, Link, Strikethrough, Underline } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";

const groups = [
  [
    { label: "Bold", shortcut: "⌘B", icon: Bold },
    { label: "Italic", shortcut: "⌘I", icon: Italic },
    { label: "Underline", shortcut: "⌘U", icon: Underline },
    { label: "Strikethrough", shortcut: "⌘⇧X", icon: Strikethrough },
  ],
  [
    { label: "Link", shortcut: "⌘K", icon: Link },
    { label: "Inline code", shortcut: "⌘E", icon: Code },
  ],
];

export default function TooltipDemo() {
  return (
    <div
      role="group"
      aria-label="Text formatting"
      className="flex items-center gap-0.5 rounded-xl border border-white/10 bg-white/[0.03] p-1"
    >
      {groups.map((tools, index) => (
        <div key={index} className="flex items-center gap-0.5">
          {index > 0 && <span aria-hidden className="mx-1 h-4 w-px bg-white/10" />}
          {tools.map(({ label, shortcut, icon: Icon }) => (
            <Tooltip
              key={label}
              content={
                <span className="flex items-center gap-2">
                  {label}
                  <kbd className="font-sans text-[11px] tracking-wide text-white/45">{shortcut}</kbd>
                </span>
              }
            >
              <Button variant="ghost" size="icon" aria-label={label} className="size-8 rounded-lg">
                <Icon />
              </Button>
            </Tooltip>
          ))}
        </div>
      ))}
    </div>
  );
}
```

### Sides

```tsx
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";

const nudges = [
  { side: "top", label: "Nudge up", key: "↑", icon: ArrowUp, cell: "col-start-2 row-start-1" },
  { side: "left", label: "Nudge left", key: "←", icon: ArrowLeft, cell: "col-start-1 row-start-2" },
  { side: "right", label: "Nudge right", key: "→", icon: ArrowRight, cell: "col-start-3 row-start-2" },
  { side: "bottom", label: "Nudge down", key: "↓", icon: ArrowDown, cell: "col-start-2 row-start-3" },
] as const;

export default function TooltipSides() {
  return (
    <div className="grid grid-cols-3 grid-rows-3 place-items-center gap-1.5">
      <span
        aria-hidden
        className="col-start-2 row-start-2 size-9 rounded-lg border border-dashed border-white/20 bg-white/[0.03]"
      />
      {nudges.map(({ side, label, key, icon: Icon, cell }) => (
        <div key={side} className={cell}>
          <Tooltip
            side={side}
            content={
              <span className="flex items-center gap-2">
                {label}
                <kbd className="font-sans text-white/45">{key}</kbd>
              </span>
            }
          >
            <Button variant="ghost" size="icon" aria-label={label} className="size-9">
              <Icon />
            </Button>
          </Tooltip>
        </div>
      ))}
    </div>
  );
}
```

### Colors

Pass a palette name or any hex value to color.

```tsx
import { Tooltip } from "@/components/ui/tooltip";

const services = [
  { name: "API", color: "green", hex: "#34d399", detail: "Operational · 99.99% uptime this month" },
  { name: "Webhooks", color: "amber", hex: "#fbbf24", detail: "Degraded · p95 latency 1.8s in eu-west-1" },
  { name: "Edge network", color: "red", hex: "#ff4d5e", detail: "Outage · investigating 503s in ap-south-1" },
  { name: "Builds", color: "blue", hex: "#4f8bff", detail: "Maintenance · queue paused until 14:00 UTC" },
] as const;

export default function TooltipColors() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {services.map((service) => (
        <Tooltip key={service.name} color={service.color} content={service.detail}>
          <button
            type="button"
            className="inline-flex h-8 items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white/70 outline-none transition-colors duration-150 hover:border-white/20 hover:text-white/90 focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
          >
            <span
              aria-hidden
              className="size-1.5 rounded-full"
              style={{ backgroundColor: service.hex, boxShadow: `0 0 8px ${service.hex}` }}
            />
            {service.name}
          </button>
        </Tooltip>
      ))}
    </div>
  );
}
```

### On a disabled button

Disabled buttons get no pointer or focus events, so wrap them in a focusable span.

```tsx
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";

export default function TooltipDisabled() {
  return (
    <div className="flex items-center gap-3">
      <Button variant="ghost">Save draft</Button>
      <Tooltip content="Add a title before publishing">
        {/* Disabled buttons ignore the pointer and can't take focus, so the span does both. */}
        <span
          tabIndex={0}
          className="inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
        >
          <Button variant="solid" disabled>
            Publish
          </Button>
        </span>
      </Tooltip>
    </div>
  );
}
```

### Longer text

Tooltips stay on one line by default. Set a max width and whitespace-normal through className to wrap.

```tsx
import { Info } from "lucide-react";
import { Tooltip } from "@/components/ui/tooltip";

export default function TooltipWrapping() {
  return (
    <div className="flex items-center gap-1.5 text-sm text-white/70">
      Usage-based billing
      <Tooltip
        side="right"
        content="Billed per 1,000 requests at the end of each cycle. The first 50,000 requests every month are free."
        className="max-w-[240px] whitespace-normal leading-5"
      >
        <button
          type="button"
          aria-label="How usage-based billing works"
          className="flex size-6 items-center justify-center rounded-full text-white/40 outline-none transition-colors duration-150 hover:text-white/80 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
        >
          <Info className="size-4" aria-hidden />
        </button>
      </Tooltip>
    </div>
  );
}
```

## API reference

### Tooltip

Other props, including className, go to the tooltip bubble.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `content` | `ReactNode` | — | What the tooltip says. Keep it short and non-interactive. |
| `children` | `ReactElement` | — | The trigger. A single element that accepts focus and aria-describedby. |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"top"` | Side of the trigger the tooltip appears on. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along that side. |
| `delay` | `number` | `300` | Milliseconds before opening on hover. Focus opens immediately and closing is always immediate. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `disabled` | `boolean` | `false` | Never opens. |
| `open` | `boolean` | — | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the tooltip wants to open or close. |

## Accessibility

- The trigger gets aria-describedby pointing at the tooltip while it's open.
- Opens on mouse hover and keyboard focus, never on touch, so don't put anything essential in a tooltip.
- Escape, blur, pointer down and leaving the trigger all close it; you can move the pointer onto the tooltip without it closing.
- Positioned with CSS, not measured: it won't flip at the viewport edge and an overflow-hidden ancestor will clip it.

## Source

`components/ui/tooltip.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";

const EASE = [0.23, 1, 0.32, 1] as const;
const SKIP_DELAY_WINDOW = 300;

// Shared by every tooltip: once one has been open, the next opens without a delay.
let lastClosedAt = 0;

function markClosed() {
  lastClosedAt = Date.now();
}

function closedRecently() {
  return Date.now() - lastClosedAt < SKIP_DELAY_WINDOW;
}

function isFocusVisible(element: Element) {
  try {
    return element.matches(":focus-visible");
  } catch {
    return true;
  }
}

const sides: Record<Side, string> = {
  top: "bottom-full pb-2",
  bottom: "top-full pt-2",
  left: "right-full pr-2",
  right: "left-full pl-2",
};

const aligns: Record<"x" | "y", Record<Align, string>> = {
  x: { start: "left-0", center: "left-1/2 -translate-x-1/2", end: "right-0" },
  y: { start: "top-0", center: "top-1/2 -translate-y-1/2", end: "bottom-0" },
};

const arrowSides: Record<Side, string> = {
  top: "-bottom-1 border-b border-r",
  bottom: "-top-1 border-l border-t",
  left: "-right-1 border-r border-t",
  right: "-left-1 border-b border-l",
};

const arrowAligns: Record<"x" | "y", Record<Align, string>> = {
  x: { start: "left-2.5", center: "left-1/2 -ml-1", end: "right-2.5" },
  y: { start: "top-2.5", center: "top-1/2 -mt-1", end: "bottom-2.5" },
};

// Where the arrow sits, so the bubble grows out of it.
const origins: Record<Align, string> = {
  start: "14px",
  center: "50%",
  end: "calc(100% - 14px)",
};

// The bubble starts nudged toward the trigger and settles outward.
const nudges: Record<Side, { x?: number; y?: number }> = {
  top: { y: 4 },
  bottom: { y: -4 },
  left: { x: 4 },
  right: { x: -4 },
};

export interface TooltipProps
  extends Omit<
    React.HTMLAttributes<HTMLSpanElement>,
    "content" | "color" | "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
  > {
  content: React.ReactNode;
  /** The trigger. A single element that accepts focus and ARIA props. */
  children: React.ReactElement;
  side?: Side;
  align?: Align;
  /** Milliseconds before opening on hover. Closing is immediate. */
  delay?: number;
  /** Palette name or hex. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}

const Tooltip = React.forwardRef<HTMLSpanElement, TooltipProps>(
  (
    {
      content,
      children,
      side = "top",
      align = "center",
      delay = 300,
      color,
      disabled = false,
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      className,
      style,
      ...props
    },
    ref,
  ) => {
    const id = React.useId();
    const reduceMotion = useReducedMotion();
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
    const [instant, setInstant] = React.useState(false);
    const timer = React.useRef<number | undefined>(undefined);
    const isControlled = openProp !== undefined;
    const open = !disabled && (isControlled ? openProp : uncontrolledOpen);

    const setOpen = React.useCallback(
      (next: boolean) => {
        if (next === open) return;
        if (!next) markClosed();
        if (!isControlled) setUncontrolledOpen(next);
        onOpenChange?.(next);
      },
      [open, isControlled, onOpenChange],
    );

    React.useEffect(() => () => window.clearTimeout(timer.current), []);

    React.useEffect(() => {
      if (!open) return;
      const onKeyDown = (event: KeyboardEvent) => {
        if (event.key !== "Escape") return;
        // Close the tooltip first, not the dialog or menu it sits in.
        event.preventDefault();
        setOpen(false);
      };
      document.addEventListener("keydown", onKeyDown);
      return () => document.removeEventListener("keydown", onKeyDown);
    }, [open, setOpen]);

    const show = (immediate: boolean) => {
      if (disabled) return;
      window.clearTimeout(timer.current);
      const sequential = closedRecently();
      if (immediate || sequential || delay <= 0) {
        setInstant(sequential);
        setOpen(true);
        return;
      }
      timer.current = window.setTimeout(() => {
        setInstant(false);
        setOpen(true);
      }, delay);
    };

    const hide = () => {
      window.clearTimeout(timer.current);
      setOpen(false);
    };

    const child = children as React.ReactElement<React.HTMLAttributes<HTMLElement>>;
    const describedBy =
      [child.props["aria-describedby"], open ? id : undefined].filter(Boolean).join(" ") ||
      undefined;

    const trigger = React.cloneElement(child, {
      "aria-describedby": describedBy,
      onFocus: (event: React.FocusEvent<HTMLElement>) => {
        child.props.onFocus?.(event);
        if (!event.defaultPrevented && isFocusVisible(event.currentTarget)) show(true);
      },
      onBlur: (event: React.FocusEvent<HTMLElement>) => {
        child.props.onBlur?.(event);
        hide();
      },
    });

    const axis = side === "top" || side === "bottom" ? "x" : "y";
    const along = origins[align];
    const transformOrigin = {
      top: `${along} 100%`,
      bottom: `${along} 0%`,
      left: `100% ${along}`,
      right: `0% ${along}`,
    }[side];

    return (
      <span
        className="relative inline-flex"
        onPointerEnter={(event) => {
          if (event.pointerType === "mouse") show(false);
        }}
        onPointerLeave={hide}
        onPointerDown={hide}
      >
        {trigger}
        <AnimatePresence>
          {open && (
            <span key="tooltip" className={cn("absolute z-50", sides[side], aligns[axis][align])}>
              <motion.span
                ref={ref}
                id={id}
                role="tooltip"
                initial={
                  instant
                    ? false
                    : reduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, scale: 0.96, ...nudges[side] }
                }
                animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
                exit={
                  reduceMotion
                    ? { opacity: 0, transition: { duration: 0.1 } }
                    : { opacity: 0, scale: 0.96, transition: { duration: 0.1, ease: EASE } }
                }
                transition={{ duration: 0.15, ease: EASE }}
                style={{ ...neonVars(color, style), transformOrigin }}
                className={cn(
                  "[--neon:var(--neon-accent,34_211_238)]",
                  "relative block w-max whitespace-nowrap rounded-lg border border-[rgb(var(--neon)/0.4)] bg-[#0a0b11] px-2.5 py-1.5 text-xs leading-4 text-white/90",
                  "shadow-[0_0_14px_-2px_rgb(var(--neon)/0.35),0_8px_20px_-8px_rgb(0_0_0/0.8)]",
                  className,
                )}
                {...props}
              >
                <span
                  aria-hidden
                  className={cn(
                    "absolute size-2 rotate-45 border-[rgb(var(--neon)/0.4)] bg-[#0a0b11]",
                    arrowSides[side],
                    arrowAligns[axis][align],
                  )}
                />
                {content}
              </motion.span>
            </span>
          )}
        </AnimatePresence>
      </span>
    );
  },
);
Tooltip.displayName = "Tooltip";

export { Tooltip };
```
