Toolbar Tooltips
An icon toolbar whose single tooltip glides between buttons instead of fading in and out for each one.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.json'use client'
import { useRef, useState, type ReactNode } from 'react'
import './toolbar-tooltips.css'
export interface ToolItem { id: string; label: string; shortcut?: string; icon: ReactNode; sep?: boolean }
export interface ToolbarTooltipsProps {
items: ToolItem[]
onAction?: (id: string) => void
delay?: number
fill?: 'dark' | 'light'
className?: string
}
/** Icon toolbar with one tooltip that glides between buttons. */
export function ToolbarTooltips({ items, onAction, delay = 400, fill = 'dark', className }: ToolbarTooltipsProps) {
const [tip, setTip] = useState<{ i: number; x: number } | null>(null)
const [open, setOpen] = useState(false)
const [warm, setWarm] = useState(false)
const [pressed, setPressed] = useState<string[]>([])
const t = useRef<ReturnType<typeof setTimeout> | null>(null)
const cool = useRef<ReturnType<typeof setTimeout> | null>(null)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const tipRef = useRef<HTMLSpanElement>(null)
const show = (i: number) => {
const el = refs.current[i]
if (!el) return
if (cool.current) clearTimeout(cool.current)
const center = el.offsetLeft + el.offsetWidth / 2
setTip({ i, x: center })
if (open || warm) { setOpen(true); return }
if (t.current) clearTimeout(t.current)
t.current = setTimeout(() => { setOpen(true); setWarm(true) }, delay)
}
const hide = () => {
if (t.current) clearTimeout(t.current)
setOpen(false)
cool.current = setTimeout(() => setWarm(false), 500)
}
const w = tipRef.current?.offsetWidth ?? 0
const cur = tip ? items[tip.i] : null
return (
<div role="toolbar" aria-label="Formatting" className={['sd-tbt', className].filter(Boolean).join(' ')} data-fill={fill} onMouseLeave={hide}>
{items.map((it, i) => (
<span key={it.id} style={{ display: 'contents' }}>
{it.sep && <span className="sd-tbt__sep" aria-hidden />}
<button
ref={(el) => { refs.current[i] = el }}
type="button"
className="sd-tbt__b"
aria-label={it.label}
aria-pressed={pressed.includes(it.id)}
aria-keyshortcuts={it.shortcut}
tabIndex={i === (tip?.i ?? 0) ? 0 : -1}
onMouseEnter={() => show(i)}
onFocus={() => show(i)}
onBlur={hide}
onClick={() => { setPressed((p) => (p.includes(it.id) ? p.filter((x) => x !== it.id) : [...p, it.id])); onAction?.(it.id) }}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
if (d) { e.preventDefault(); refs.current[(i + d + items.length) % items.length]?.focus() }
}}
>
{it.icon}
</button>
</span>
))}
<span ref={tipRef} className="sd-tbt__tip" aria-hidden data-open={open || undefined} data-glide={warm || undefined} style={{ '--x': `${(tip?.x ?? 0) - w / 2}px` } as React.CSSProperties}>
{cur?.label}{cur?.shortcut && <kbd>{cur.shortcut}</kbd>}
</span>
</div>
)
}.sd-tbt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tbt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tbt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tbt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tbt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tbt { position: relative; display: inline-flex; gap: 2px; padding: 5px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-tbt__b { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 9px; color: var(--mute); transition: color 150ms, background-color 150ms; }
.sd-tbt__b:hover, .sd-tbt__b:focus-visible, .sd-tbt__b[aria-pressed='true'] { color: var(--fg); background: var(--soft); }
.sd-tbt__b svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-tbt__sep { width: 1px; margin: 6px 3px; background: var(--line); }
.sd-tbt__tip { position: absolute; bottom: calc(100% + 8px); left: 0; display: flex; gap: 8px; align-items: center; padding: 6px 9px; border-radius: 8px; background: var(--strong); color: var(--on); font-size: 12px; font-weight: 500; white-space: nowrap; pointer-events: none; opacity: 0; transform: translateX(var(--x)) translateY(4px); transition: opacity 160ms, transform 160ms; }
.sd-tbt__tip[data-open] { opacity: 1; transform: translateX(var(--x)); }
.sd-tbt__tip[data-glide] { transition: opacity 160ms, transform 240ms cubic-bezier(0.3, 1.1, 0.4, 1); }
.sd-tbt__tip kbd { font: 500 11px/1 ui-monospace, monospace; opacity: 0.6; }
@media (prefers-reduced-motion: reduce) { .sd-tbt *, .sd-tbt *::before, .sd-tbt *::after { animation: none !important; transition: none !important; } }Overview
After the first tooltip opens with a short delay, moving across the toolbar slides the same tooltip to the next button and swaps its text instantly, the way native app toolbars feel.
Leaving the toolbar closes it; coming back within half a second skips the delay. Each tooltip shows its keyboard shortcut.
When to use
- Editors, canvases and media controls with icon-only buttons.
When not to use
- Buttons that already show text.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.json$
npx shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy toolbar-tooltips.tsx and toolbar-tooltips.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ToolbarTooltips } from '@/components/sundae/toolbar-tooltips'
<ToolbarTooltips />Props
| Prop | Type | Default |
|---|---|---|
items*Buttons. | { id: string; label: string; shortcut?: string; icon: ReactNode }[] | — |
onActionCalled on click. | (id: string) => void | — |
delayFirst-open delay in ms. | number | 400 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="toolbar" with roving focus via arrow keys.
- Each button is labelled; the tooltip is aria-hidden duplicate text so nothing is read twice.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tbt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Move between tools
FAQ
- Is Toolbar Tooltips free?
- Yes. Toolbar Tooltips is free for personal and commercial projects, with full source on this page.
- How do I install Toolbar Tooltips?
- Run npx shadcn@latest add https://sundaeui.dev/r/toolbar-tooltips.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Toolbar Tooltips support keyboard and screen readers?
- role="toolbar" with roving focus via arrow keys. Each button is labelled; the tooltip is aria-hidden duplicate text so nothing is read twice.
- Does Toolbar Tooltips respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →- Pro
Magnify Dock
Navigation · Playful - Pro
Command Palette
Navigation · Refined Orbit Nav
Navigation · PlayfulSegmented Control
Navigation · RefinedPagination
Navigation · RefinedProgress Steps
Navigation · Refined