Forms and inputsTooltip
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTooltip
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.
Labels wait a moment, then follow you instantly between buttons.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add tooltipReact
npx designme add tooltip --reactThe 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.
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
- Tools come in
groups, with a thin divider between each group. - Each tip shows the tool's
tiptext, or itslabel, plus an optionalkbdshortcut. - The first tip waits a moment, and moving straight to the next button shows its tip at once.
- Hovering an avatar opens a person card after
cardDelayms, 300 by default, with name, role and status. - A person marked
workingshows the agent's thinking dots instead of a green dot. openTipandopenCardshow a tip or card by key from your own code, for a guided tour.
- Each icon button gets its
labelas anaria-label, and avatar buttons are named with the person's name. - Tips render with
role="tooltip"and also open on keyboard focus, not only on hover. - Person cards open when an avatar receives keyboard focus and close on blur.
- The card is marked
aria-hiddenwhile it is not showing.
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>
);
}
Props
Everything Tooltip takes.
| Prop | Type | What it does |
|---|---|---|
groups | TooltipTool[][] | The toolbar's buttons, in groups with a line between them |
people | TooltipPerson[] | Avatars at the end of the toolbar. Hovering one shows a card about the person |
note | ReactNode | A quiet line under the toolbar |
cardDelay | number | How long the person card waits before it shows |
openTip | string | null | Shows this tool's tip, by key, as if it were hovered. Leave it out to let the pointer decide |
openCard | string | null | Shows this person's card, by key, as if they were hovered. Leave it out to let the pointer decide |
className | string |
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;
});
});
Questions
What people ask about it.
- Can the label read differently from the button's accessible name?
- Yes.
labelis the name screen readers hear, andtipis 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
peoplewith astatuslike "Auditing contrast, 2 of 6 checks" and setworking: true. The card then shows the thinking dots next to the status. - How do I open a tip from code, for a walkthrough?
- Set
openTipto the tool's key, oropenCardto a person's key. Clear it to close, and leave it undefined to let the pointer decide again.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.