Skip to content
Star on GitHub
Forms and inputsTooltip
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Tooltip

Quiet labels on hover, with shortcuts and a richer preview card. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add tooltip

Labels wait a moment, then follow you instantly between buttons.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add tooltip

React

$npx designme add tooltip --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Tooltip is a toolbar of icon buttons whose labels appear after a short pause and then follow the pointer from button to button without waiting again. Avatars at the end open a larger card about each person, including the agent, with what they are doing right now. It shows a team and its agent working side by side in the same file.

  • When a toolbar is all icons and each one needs a name and a keyboard shortcut
  • When people and agents share a workspace and you want to show who is doing what
  • When an agent's presence should read as a teammate, with its own status line
  • When onboarding should point at a specific control by showing its tip on cue
03

Usage

Import it and drive it from your own state.

Import

import { Tooltip } from '@/components/designme/tooltip/Tooltip';

Example

The demo above as a React component, 31 lines you can copy.

'use client';
// The tooltip card from designme.dev: a toolbar with shortcut tips and two people with a card each.
import { useRef, useState } from 'react';
import { useScript, useTakeover } from '../lib/useScript';
import { Tooltip, type TooltipPerson } from './Tooltip';

const PEOPLE: TooltipPerson[] = [
  { key: 'mk', initials: 'MK', color: 'var(--dm-av-l)', name: 'Maya Kim', role: 'Design lead', status: 'Editing Pricing, frame 2' },
  { key: 'da', initials: 'DA', color: 'var(--dm-av-b)', name: 'Design agent', role: 'Working for Maya', status: 'Auditing contrast, 2 of 6 checks', working: true }
];

export default function TooltipDemo() {
  const [tip, setTip] = useState<string | null>();
  const [card, setCard] = useState<string | null>();
  // once it has appeared it shows what it does: a shortcut tip, then the agent's card. Pointing at it takes over, as does
  // clicking, focusing or typing in it, and puts away what the demo had open unless the pointer is on it
  const touched = useRef(false), ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref, () => setTip(undefined));
  const yours = () => touched.current || mine();
  useScript(async ({ wait }) => {
    await wait(1300); if (yours()) return;
    setTip('ask'); await wait(1700); setTip(undefined); if (yours()) return;
    await wait(500); if (yours()) return;
    setCard('da'); await wait(2600); setCard(null);
  });
  return (
    <div ref={ref} style={{ width: '100%' }} onPointerEnter={() => { touched.current = true; setTip(undefined); setCard(c => (c ? null : c)); }}>
      <Tooltip people={PEOPLE} openTip={tip} openCard={card} note="Labels wait a moment, then follow you instantly between buttons." />
    </div>
  );
}
04

Props

Everything Tooltip takes.

PropTypeWhat it does
groupsTooltipTool[][]The toolbar's buttons, in groups with a line between them
peopleTooltipPerson[]Avatars at the end of the toolbar. Hovering one shows a card about the person
noteReactNodeA quiet line under the toolbar
cardDelaynumberHow long the person card waits before it shows
openTipstring | nullShows this tool's tip, by key, as if it were hovered. Leave it out to let the pointer decide
openCardstring | nullShows this person's card, by key, as if they were hovered. Leave it out to let the pointer decide
classNamestring
05

Code

The full source, as the CLI installs it.

Tooltip.tsxReact · 123 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { Icon, type IconName } from '../lib/icons';
import { Avatar, Dot, Think, Tip } from '../lib/motion';
import '../designme.css';
import './tooltip.css';

export interface TooltipTool {
  key: string;
  icon: IconName;
  /** The button's name, read out by screen readers */
  label: string;
  /** The tooltip, when it should say more than the name, like "Ask the agent". Defaults to the label */
  tip?: string;
  /** A shortcut shown after the tip, like "⌘Z" */
  kbd?: string;
  onClick?: () => void;
}

export interface TooltipPerson {
  key: string;
  initials: string;
  /** The avatar's colour, like 'var(--dm-av-l)' */
  color: string;
  name: string;
  /** The quiet line under the name, like "Design lead" */
  role: string;
  /** What they are doing now, like "Editing Pricing, frame 2" */
  status: string;
  /** Shows the agent's thinking dots instead of the green dot */
  working?: boolean;
}

export interface TooltipProps {
  /** The toolbar's buttons, in groups with a line between them */
  groups?: TooltipTool[][];
  /** Avatars at the end of the toolbar. Hovering one shows a card about the person */
  people?: TooltipPerson[];
  /** A quiet line under the toolbar */
  note?: ReactNode;
  /** How long the person card waits before it shows */
  cardDelay?: number;
  /** Shows this tool's tip, by key, as if it were hovered. Leave it out to let the pointer decide */
  openTip?: string | null;
  /** Shows this person's card, by key, as if they were hovered. Leave it out to let the pointer decide */
  openCard?: string | null;
  className?: string;
}

const DEFAULT_GROUPS: TooltipTool[][] = [
  [{ key: 'undo', icon: 'undo', label: 'Undo', kbd: '⌘Z' }, { key: 'comment', icon: 'edit', label: 'Comment', kbd: 'C' }, { key: 'ask', icon: 'spark', label: 'Ask agent', tip: 'Ask the agent', kbd: '⌘J' }],
  [{ key: 'share', icon: 'users', label: 'Share', tip: 'Share with your team' }]
];

/** A toolbar whose labels wait a moment, then follow you instantly between buttons, with a richer card for people. */
export function Tooltip({ groups = DEFAULT_GROUPS, people = [], note, cardDelay = 300, openTip, openCard, className }: TooltipProps) {
  const root = useRef<HTMLDivElement>(null);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  // the card keeps its last person while it fades out
  const [card, setCard] = useState<{ person: TooltipPerson; left: number; top: number } | null>(null);
  const [show, setShow] = useState(false);
  useEffect(() => () => clearTimeout(timer.current), []);

  const open = (person: TooltipPerson, el: HTMLElement, now = false) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      const box = root.current; if (!box) return;
      const rr = box.getBoundingClientRect(), br = el.getBoundingClientRect();
      // centred under the avatar, kept inside the component
      const left = Math.max(0, Math.min(br.left - rr.left + br.width / 2 - 115, rr.width - 230));
      setCard({ person, left, top: br.bottom - rr.top + 10 }); setShow(true);
    }, now ? 0 : cardDelay);
  };
  const close = () => { clearTimeout(timer.current); setShow(false); };
  // the avatar under the pointer: a card opened from code and then let go stays while the pointer is on its avatar
  const hovered = useRef<string | null>(null);
  // a card asked for by key opens at once under its avatar, and closes when the key is cleared
  const avatars = useRef(new Map<string, HTMLButtonElement>());
  useEffect(() => {
    if (openCard === undefined) return;
    const person = people.find(x => x.key === openCard), el = person && avatars.current.get(person.key);
    if (person && el) open(person, el, true); else if (!hovered.current) close();
  }, [openCard]); // eslint-disable-line react-hooks/exhaustive-deps

  const p = card?.person;
  return (
    <div data-dm="" ref={root} className={cx('dm-ttp', className)}>
      <div className="dm-ttp-bar dm-panel">
        {groups.map((g, i) => [
          i > 0 && <span key={'s' + i} className="dm-ttp-sep" />,
          ...g.map(t => (
            <Tip key={t.key} label={t.tip ?? t.label} kbd={t.kbd} open={openTip === t.key || undefined}>
              <button type="button" className="dm-ib" aria-label={t.label} onClick={t.onClick}><Icon name={t.icon} /></button>
            </Tip>
          ))
        ])}
        {people.length > 0 && <>
          <span className="dm-ttp-sep" />
          <span className="dm-ttp-avs">
            {people.map(person => (
              <button key={person.key} ref={el => { if (el) avatars.current.set(person.key, el); else avatars.current.delete(person.key); }} type="button" className="dm-ttp-av" aria-label={person.name}
                onMouseEnter={e => { hovered.current = person.key; open(person, e.currentTarget); }} onMouseLeave={() => { hovered.current = null; close(); }}
                onFocus={e => { if (e.currentTarget.matches(':focus-visible')) open(person, e.currentTarget, true); }} onBlur={close}>
                <Avatar initials={person.initials} color={person.color} />
              </button>
            ))}
          </span>
        </>}
      </div>
      <div className={cx('dm-ttp-card dm-panel', show && 'dm-show')} style={card ? { left: card.left, top: card.top } : undefined} aria-hidden={!show}>
        <div className="dm-ttp-ci">
          {p && <>
            <div className="dm-ttp-ch"><Avatar initials={p.initials} color={p.color} /><div><b>{p.name}</b><span>{p.role}</span></div></div>
            <div className="dm-ttp-cs">{p.working ? <span style={{ display: 'inline-flex', color: 'var(--dm-accent)' }}><Think /></span> : <Dot />}{p.status}</div>
          </>}
        </div>
      </div>
      {note && <p className="dm-muted dm-ttp-n">{note}</p>}
    </div>
  );
}
tooltip.cssReact · 21 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Tooltip ---------- */
.dm-ttp{display:flex;flex-direction:column;align-items:center;gap:var(--dm-s-4);position:relative;min-height:200px;justify-content:center}
.dm-ttp-bar{display:flex;align-items:center;gap:2px;padding:5px;border-radius:var(--dm-r-pill)}
.dm-ttp-sep{width:1px;height:18px;background:var(--dm-line);margin:0 var(--dm-s-1)}
.dm-ttp-avs{display:flex;padding-right:3px}
.dm-ttp-av{border-radius:50%;margin-left:-4px;transition:transform .3s var(--dm-ease)}
.dm-ttp-av:first-child{margin-left:0}
@media (hover:hover) and (pointer:fine){.dm-ttp-av:hover{transform:translateY(-2px)}}
.dm-ttp-av .dm-av{width:28px;height:28px;font-size:11px;box-shadow:0 0 0 2px var(--dm-surface)}
.dm-ttp-card{position:absolute;top:0;left:0;width:230px;padding:var(--dm-s-3);box-shadow:var(--dm-shadow-pop);opacity:0;transform:translateY(6px) scale(.97);filter:blur(4px);pointer-events:none;transition:opacity .25s var(--dm-ease),transform .35s var(--dm-ease),filter .25s;z-index:5}
.dm-ttp-card.dm-show{opacity:1;transform:none;filter:none}
.dm-ttp-ch{display:flex;align-items:center;gap:10px}
.dm-ttp-ch .dm-av{width:34px;height:34px;font-size:12px}
.dm-ttp-ch div{display:flex;flex-direction:column;font-size:13px}
.dm-ttp-ch b{font-weight:500}
.dm-ttp-ch div span{font-size:12px;color:var(--dm-muted)}
.dm-ttp-cs{display:flex;align-items:center;gap:6px;margin-top:10px;font-size:12px;color:var(--dm-fg2)}
.dm-ttp-cs .dm-dot{color:var(--dm-ok)}
.dm-ttp-n{font-size:12.5px;text-align:center}
tooltip.htmlHTML · 18 lines
<div class="card" data-c="tooltip">
  <div class="stage-in">
    <div class="ttp">
      <div class="ttp-bar panel">
        <button class="ib" aria-label="Undo" data-tip="Undo" data-tip-k="⌘Z"><svg class="i s"><use href="#i-undo"/></svg></button>
        <button class="ib" aria-label="Comment" data-tip="Comment" data-tip-k="C"><svg class="i s"><use href="#i-edit"/></svg></button>
        <button class="ib" aria-label="Ask agent" data-tip="Ask the agent" data-tip-k="⌘J"><svg class="i s"><use href="#i-spark"/></svg></button>
        <span class="ttp-sep"></span>
        <button class="ib" aria-label="Share" data-tip="Share with your team"><svg class="i s"><use href="#i-users"/></svg></button>
        <span class="ttp-sep"></span>
        <span class="ttp-avs"><button class="ttp-av" data-card="0"><span class="av" style="background:var(--av-l);color:var(--av-l-ink)">MK</span></button><button class="ttp-av" data-card="1"><span class="av" style="background:var(--av-b);color:var(--av-b-ink)">DA</span></button></span>
      </div>
      <div class="ttp-card panel"><div class="ttp-ci"></div></div>
      <p class="muted ttp-n">Labels wait a moment, then follow you instantly between buttons.</p>
    </div>
  </div>
</div>
tooltip.cssHTML · 20 lines
/* ---------- Tooltip ---------- */
.ttp{display:flex;flex-direction:column;align-items:center;gap:var(--s-4);position:relative;min-height:200px;justify-content:center}
.ttp-bar{display:flex;align-items:center;gap:2px;padding:5px;border-radius:var(--r-pill)}
.ttp-sep{width:1px;height:18px;background:var(--line);margin:0 var(--s-1)}
.ttp-avs{display:flex;padding-right:3px}
.ttp-av{border-radius:50%;margin-left:-4px;transition:transform .3s var(--ease)}
.ttp-av:first-child{margin-left:0}
@media (hover:hover) and (pointer:fine){.ttp-av:hover{transform:translateY(-2px)}}
.ttp-av .av{width:28px;height:28px;font-size:11px;box-shadow:0 0 0 2px var(--surface)}
.ttp-card{position:absolute;top:0;left:0;width:230px;padding:var(--s-3);box-shadow:var(--shadow-pop);opacity:0;transform:translateY(6px) scale(.97);filter:blur(4px);pointer-events:none;transition:opacity .25s var(--ease),transform .35s var(--ease),filter .25s;z-index:5}
.ttp-card.show{opacity:1;transform:none;filter:none}
.ttp-ch{display:flex;align-items:center;gap:10px}
.ttp-ch .av{width:34px;height:34px;font-size:12px}
.ttp-ch div{display:flex;flex-direction:column;font-size:13px}
.ttp-ch b{font-weight:500}
.ttp-ch div span{font-size:12px;color:var(--muted)}
.ttp-cs{display:flex;align-items:center;gap:6px;margin-top:10px;font-size:12px;color:var(--fg2)}
.ttp-cs .dot{color:var(--ok)}
.ttp-n{font-size:12.5px;text-align:center}
tooltip.jsHTML · 28 lines
DM.def('tooltip', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{undo}', I('undo')).replace('{chat}', I('edit')).replace('{spark}', I('spark')).replace('{users}', I('users')).replace('{a1}', AV('MK','var(--av-l)')).replace('{a2}', AV('DA','var(--av-b)'));
  const P = [['MK','var(--av-l)','Maya Kim','Design lead','Editing Pricing, frame 2'],['DA','var(--av-b)','Design agent','Working for Maya','Auditing contrast, 2 of 6 checks']];
  const card = $('.ttp-card', r), ci = $('.ttp-ci', r), root = $('.ttp', r); let t;
  $$('.ttp-av', r).forEach(b => { b.onmouseenter = () => { clearTimeout(t); t = setTimeout(() => { const p = P[+b.dataset.card];
      ci.innerHTML = `<div class="ttp-ch">${AV(p[0], p[1])}<div><b>${p[2]}</b><span>${p[3]}</span></div></div><div class="ttp-cs">${p[0] === 'DA' ? '<span class="think on" style="color:var(--accent-mark,var(--accent))"><i></i><i></i><i></i><i></i></span>' : '<span class="dot"></span>'}${p[4]}</div>`;
      const rr = root.getBoundingClientRect(), br = b.getBoundingClientRect(); let x = br.left - rr.left + br.width / 2 - 115; x = Math.max(0, Math.min(x, rr.width - 230));
      card.style.left = x + 'px'; card.style.top = (br.bottom - rr.top + 10) + 'px'; card.classList.add('show'); }, 300); };
    b.onmouseleave = () => { clearTimeout(t); card.classList.remove('show'); }; });
  c.offs.push(() => clearTimeout(t));
  // once it has entered, it shows what it does: a shortcut tip, then the agent's card. Pointing at it takes over, as does
  // clicking, focusing or typing in it
  // what the demo opened goes when the pointer comes in, unless it is on that very control: the visitor's own hover decides
  // from then on, so no tip or card is left over from the demo over the one they point at
  let touched = false, shown = null;
  c.on(root, 'pointerenter', () => { touched = true;
    if (shown === 'tip' && tipFor && !tipNext && root.contains(tipFor) && !tipFor.matches(':hover')) hideTip();
    if (shown === 'card' && !$('.ttp-av:hover', r)){ clearTimeout(t); card.classList.remove('show'); }
    shown = null; });
  const go = () => { if (touched || c.mine) throw 'stop'; };
  c.run(async () => {
    await c.wait(1300); go(); if (tipFor) return;
    const ask = $('[data-tip-k="⌘J"]', r); showTip(ask); shown = 'tip'; await c.wait(1700); go(); if (tipFor === ask) hideTip(true); shown = null;
    await c.wait(500); go();
    const av = $('.ttp-av[data-card="1"]', r); av.onmouseenter(); shown = 'card'; await c.wait(2600); go(); av.onmouseleave(); shown = null;
  });
});
06

Questions

What people ask about it.

Can the label read differently from the button's accessible name?
Yes. label is the name screen readers hear, and tip is the visible tooltip text when you want it to say more, like "Ask the agent" for a button named "Ask agent".
How do I show what the agent is doing in the person card?
Add the agent to people with a status like "Auditing contrast, 2 of 6 checks" and set working: true. The card then shows the thinking dots next to the status.
How do I open a tip from code, for a walkthrough?
Set openTip to the tool's key, or openCard to a person's key. Clear it to close, and leave it undefined to let the pointer decide again.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →