Skip to content
cennge-ui
GitHubv0.3.1, changelog

Tooltip

A short label for a control, shown when the pointer rests on it and when it takes keyboard focus.

Release notes

  1. Rest the pointer on a button: its name shows after 300 ms and goes when the pointer leaves.
  2. Tab to a button: the name shows at once, and Escape hides it.
  3. A click hides it too. A tap shows no tooltip, so every button also has an aria-label.
Live: the component itself, not a picture of it.

Field marks

Import
import { Tooltip } from "cennge-ui";
Size
0.9 KB gzip for this import, its dependencies aside
Pattern
WAI-ARIA tooltip: role="tooltip", the trigger's aria-describedby while shown
Keyboard
Esc
State
Controlled with isOpen and onOpenChange, or uncontrolled
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Delay
300 ms on hover, none on keyboard focus
Touch
Not shown: touch pointers, and the focus a tap gives, do not open it
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: overlays

When to use#

Use a Tooltip to name an icon button, to show the shortcut of a command, or to say where a link goes. It holds a few words, perhaps a shortcut: there is nothing inside to click, and it goes when the pointer leaves.

A tap never shows it, so put nothing there that the reader needs to finish a task. A field's rules belong in its description, an error in its error message.

Similar#

  • Popover opens on a click and can hold buttons, fields and links.
  • HoverCard previews what a link points to, a card rather than a label.

Name an icon button#

The tooltip describes its trigger, it does not name it: give an icon-only button an aria-label as well. The trigger is your own element, a Button or a link through asChild, and must take a ref.

icon-buttons.tsx
import { Button, Tooltip, toast } from "cennge-ui";

const install = "pnpm add cennge-ui";

function StarIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" aria-hidden="true">
      <path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z" />
    </svg>
  );
}

function TerminalIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="m5 7 5 5-5 5m8 0h6" />
    </svg>
  );
}

function ExternalIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M14 5h5v5m0-5-8 8M17 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h4" />
    </svg>
  );
}

export default function RepositoryActions() {
  return (
    <div className="flex gap-1">
      <Tooltip content="Star cennge-ui">
        <Button isIconOnly variant="flat" aria-label="Star" onClick={() => toast("Starred cennge-ui")}>
          <StarIcon />
        </Button>
      </Tooltip>
      <Tooltip content={`Copy “${install}”`}>
        <Button
          isIconOnly
          variant="flat"
          aria-label="Copy the install command"
          onClick={async () => {
            await navigator.clipboard.writeText(install);
            toast("Copied", { description: install });
          }}
        >
          <TerminalIcon />
        </Button>
      </Tooltip>
      <Tooltip content="github.com/Cennge/cennge-ui">
        <Button asChild isIconOnly variant="flat">
          <a href="https://github.com/Cennge/cennge-ui" aria-label="Open on GitHub">
            <ExternalIcon />
          </a>
        </Button>
      </Tooltip>
    </div>
  );
}

Colour, side and arrow#

color takes the seven colours of the library, placement the side, align the edge it lines up with, and showArrow draws an arrow in the tooltip's colour. When its side has no room it flips to the opposite one, and along its side it slides to stay 8px inside the screen.

colors.tsx
import { Button, Tooltip, toast } from "cennge-ui";

function TrashIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M5 7h14M10 7V5h4v2m-7 0 .8 11.2a1 1 0 0 0 1 .8h4.4a1 1 0 0 0 1-.8L17 7" />
    </svg>
  );
}

export default function TooltipColors() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Tooltip content="Publishes cennge-ui 0.3.2 to npm" color="primary" placement="bottom" showArrow>
        <Button color="primary" onClick={() => toast.success("Published", { description: "cennge-ui@0.3.2" })}>
          Publish
        </Button>
      </Tooltip>
      <Tooltip content="Deletes fix/select-chevron for good" color="danger" placement="top" align="start" showArrow>
        <Button isIconOnly color="danger" variant="flat" aria-label="Delete branch" onClick={() => toast("Branch deleted")}>
          <TrashIcon />
        </Button>
      </Tooltip>
    </div>
  );
}

One delay for the whole app#

On its own, every Tooltip waits 300 ms before it opens on hover, and delay changes that for one. Under CenngeProvider the tooltips share a provider: tooltipDelay sets the delay for all of them, and once one has opened, moving straight to the next opens it without the wait. Rest on one script below, then slide along the row.

provider.tsx
import { Button, CenngeProvider, Tooltip, toast } from "cennge-ui";

const scripts = [
  { name: "Install", command: "pnpm install" },
  { name: "Typecheck", command: "pnpm typecheck" },
  { name: "Unit tests", command: "pnpm test" },
  { name: "End-to-end", command: "pnpm test:e2e" },
  { name: "Build", command: "pnpm build" },
  { name: "Size", command: "pnpm size" },
];

export default function SharedDelay() {
  // Usually once, around the whole app.
  return (
    <CenngeProvider tooltipDelay={500}>
      <div className="flex flex-wrap gap-1.5">
        {scripts.map((script) => (
          <Tooltip key={script.name} content={<code className="font-mono">{script.command}</code>}>
            <Button
              size="sm"
              variant="flat"
              onClick={async () => {
                await navigator.clipboard.writeText(script.command);
                toast("Copied", { description: script.command });
              }}
            >
              {script.name}
            </Button>
          </Tooltip>
        ))}
      </div>
    </CenngeProvider>
  );
}

Accessibility#

  • While it is shown, the tooltip has role="tooltip" and the trigger's aria-describedby points at it, so screen readers read it after the trigger's name.
  • It opens on hover after the delay and on keyboard focus at once. Escape, a click on the trigger or the pointer leaving closes it; the pointer can move onto the tooltip without closing it. One tooltip shows at a time.
  • A scroll of the page or of any box around the trigger closes it. That includes the scroll the browser makes when Tab reaches a trigger below the fold, so that trigger shows no tooltip until it is focused again.
  • isDisabled renders the trigger alone, with no tooltip.
  • The end-to-end tests hover a button and leave it in Chromium, Firefox and WebKit on desktop, and on every profile, the phones included, Tab to it and press Escape. Hover is not tested on the phone profiles: touch screens have no hover.

Styling#

className goes to the tooltip, which is at most 20rem wide, in text-xs. The default colour is bg-surface-4 with a border; the other colours fill it with the colour and its foreground.

Props#

PropTypeDefault
contentrequired
React.ReactNodeWhat the tooltip says.
–
childrenrequired
React.ReactElementThe trigger. Must be a single element that accepts a ref (a Button, an <a>…).
–
color
"default""primary""secondary""success""warning""danger""info"
"default"
placement
"left""right""top""bottom"
"top"
align
"start""center""end"
"center"
offset
numberGap between trigger and tooltip, in px.
6
showArrow
boolean
false
delay
numberDelay before opening, in ms. Defaults to the provider's tooltipDelay (300).
–
isOpen
boolean
–
defaultOpen
boolean
–
onOpenChange
(isOpen: boolean) => void
–
isDisabled
boolean
–
className
string
–