← All components

Tooltip

A name for an icon-only control, in an inverted chip that fades in beside it.

GitHubXCopy link

The idea

Shown on hover and on focus, so the keyboard gets the same answer as the pointer, and it fades rather than travels: it appears beside something you are already looking at, and movement there only competes with what it names. For a row of icons that share one tooltip sliding between them, use Dock, which owns that as one piece of state.

Composes Dock.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Tooltip — usage5 lines
import { Tooltip } from "mihirmodi-ui";

<Tooltip label="GitHub" side="bottom">
  <a href={repo} aria-label="GitHub"><GitHubMark /></a>
</Tooltip>

Props

PropNotes
labelReactNodeThe name. Keep the control's own aria-label too — a tooltip is not an accessible name.
side"top" | "right" | "bottom" | "left"Where it appears.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/Tooltip.tsx52 lines
"use client";

import { useState, type ReactNode } from "react";

export type TooltipProps = {
  label: ReactNode;
  children: ReactNode;
  side?: "top" | "right" | "bottom" | "left";
  className?: string;
};

/**
 * A name for an icon-only control, in an inverted chip beside it.
 *
 * Shown on hover and on focus, so the keyboard gets the same answer as the
 * pointer, and it is `role="tooltip"` with nothing interactive inside — a
 * tooltip is a label, not a place to put a button. It fades rather than
 * travels: it is appearing beside something you are already looking at, and
 * movement there only competes with the thing it is naming.
 *
 * For a row of icons that share one tooltip sliding between them, use `Dock`,
 * which owns that as one piece of state.
 */
export function Tooltip({ label, children, side = "top", className = "" }: TooltipProps) {
  const [on, setOn] = useState(false);
  const pos = {
    right: "left-full top-1/2 -translate-y-1/2 ml-2",
    left: "right-full top-1/2 -translate-y-1/2 mr-2",
    top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
    bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  }[side];

  return (
    <span
      className={`relative inline-flex ${className}`}
      onMouseEnter={() => setOn(true)}
      onMouseLeave={() => setOn(false)}
      onFocus={() => setOn(true)}
      onBlur={() => setOn(false)}
    >
      {children}
      <span
        role="tooltip"
        className={`pointer-events-none absolute z-20 inline-flex h-[22px] items-center whitespace-nowrap rounded-md bg-ink px-2 text-[11px] font-medium leading-none text-background-100 shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)] transition-opacity duration-200 ease-out motion-reduce:transition-none ${pos} ${
          on ? "opacity-100" : "opacity-0"
        }`}
      >
        {label}
      </span>
    </span>
  );
}