Skip to content

Repository files navigation

Contemporary React Tooltip

Contemporary React Tooltip npm version npm downloads

✨ Demo · 📖 Storybook

8kB gzipped, no dependency React tooltip built on modern web features:

  • CSS anchor positioning: the bubble pins itself to its trigger with anchor-name / position-anchor / anchor(); no JS measuring on scroll. Old browsers degrade gracefully to title tooltip, no polyfill, no extra dependency.
  • Popover API: the bubble lives in browser's top layer, it escapes overflow: hidden and z-index stacking with no portal.
  • Pure CSS shape: the rounded bubble and its arrow are one clip-path: polygon(...), no borders, no pseudo-elements or SVG.
  • Zero-config styling: each component injects its own stylesheet slice at runtime; no CSS import and no bundler CSS loader required.
Mode Markup
Wrapping (default) <Tooltip content="Saved">
<button>Save</button>
</Tooltip>
External by ref <button ref={ref}>Save</button>
<Tooltip anchorRef={ref} content="Saved" />
External by name <button style={{ anchorName: '--s' }}>Save</button>
<Tooltip anchorName="--s" content="Saved"
open={open} onOpenChange={setOpen} />

Components

Each component injects its own stylesheet slice at runtime, so all three work standalone with no CSS import.

Export Role
Tooltip Behaviour, triggers, positioning.
TooltipShape The bubble, e.g the clip-path shape + arrow.
TooltipAnchor The anchor part of CSS anchor positioning.

Tooltip props

Prop Type Default Notes
children ReactNode – The trigger element (wrapping mode).
content ReactNode – The bubble content.
placement 'top' | 'bottom' | 'left' | 'right' 'top' Preferred side of the anchor.
arrowPlacement 'start' | 'center' | 'end' 'center' Which way the bubble extends; arrow stays on the anchor centre (see below).
triggers ('hover' | 'focus' | 'click')[] ['hover', 'focus'] Interactions that reveal the tooltip.
timings TooltipTimings see below Show/hide delays, click guard, minimum visible time (ms).
offset string '0rem' Gap between anchor and bubble (any CSS length).
flip boolean true Flip to the opposite side when it would overflow.
animationDuration string '0.2s' Fade duration (CSS time); the flip slide takes twice as long.
defaultOpen boolean false Initial open state (uncontrolled).
open boolean – Controlled open state; pair with onOpenChange.
onOpenChange (open: boolean) => void – Fires when the open state should change.
bubbleStyle TooltipBubbleStyle – Bubble appearance (see below).
className string – Applied to the popover element.
style CSSProperties – Applied to the popover element.
anchorRef RefObject<HTMLElement> – Attach to an existing element instead of wrapping children. See External anchor below.
anchorName string – Use this CSS anchor name verbatim. See External anchor below.

timings

All in ms; pass any subset, the rest keep their defaults.

Field Default Notes
showDelay 200 Before showing on hover/focus; click is instant.
hideDelay 100 Before hiding on hover-out/blur; click is instant.
clickGuard 1000 A click this soon after a hover/focus reveal keeps it open instead of closing.
minVisibleTime 1000 A hover/focus-revealed tooltip stays at least this long.
<Tooltip content="Patient" timings={{ showDelay: 500, minVisibleTime: 0 }}>
  <button>Hover me</button>
</Tooltip>

arrowPlacement

The arrow always points at the anchor's centre, arrowPlacement only chooses which way the bubble body extends from it. 'center' (default) centres the bubble on the anchor; 'start' keeps the arrow near the bubble's leading edge so the body grows toward the trailing side; 'end' mirrors that. Handy when the anchor sits near a viewport edge and you want the bubble to grow the other way. The axis follows placement: left→right for top/bottom, top→bottom for left/right.

<Tooltip content="Aligned to the start" placement="top" arrowPlacement="start">
  <button>Hover me</button>
</Tooltip>

bubbleStyle

Per-instance look of the bubble. Pass any subset; omitted fields fall back to the library defaults. Most fields are applied as CSS custom properties on the bubble. The one exception is cornerSegments, which selects a .corners-N class.

Field Type Default Notes
background string '#000' Bubble background (any CSS background).
color string '#fff' Text color.
fontSize string '0.875rem' Bubble font size.
radius string '0.5rem' Corner radius (any CSS length).
arrowSize string '0.5rem' Arrow size (half-diagonal).
paddingX string '0.7rem' Horizontal padding.
paddingY string '0.4rem' Vertical padding.
maxWidth string '16rem' Maximum bubble width.
cornerSegments 3 | 5 | 7 5 Straight segments per rounded corner, higher is smoother.
<Tooltip
  content="Custom bubble"
  animationDuration="0.25s"
  bubbleStyle={{
    background: '#2563eb',
    color: '#fff',
    radius: '0.8rem',
    arrowSize: '0.6rem',
    paddingX: '1rem',
    paddingY: '0.5rem',
    maxWidth: '20rem',
    cornerSegments: 7,
  }}
>
  <button>Hover me</button>
</Tooltip>

Controlled usage

const [open, setOpen] = useState(false);

<Tooltip open={open} onOpenChange={setOpen} content="Controlled">
  <button>Anchor</button>
</Tooltip>;

External anchor (skip the wrapper)

When you'd rather attach the tooltip to an element you already render. Without Tooltip wrapping it in an extra <div>. Pass anchorRef and omit children. Tooltip writes anchor-name onto the referenced element, wires the configured triggers to it, and mirrors aria-describedby on it for accessibility:

const btnRef = useRef<HTMLButtonElement>(null);

<>
  <button ref={btnRef}>Save</button>
  <Tooltip anchorRef={btnRef} content="Saved" />
</>;

If you'd rather own the CSS anchor name yourself, use anchorName. In this mode Tooltip has no handle to your element, so it cannot wire triggers; pair with controlled open / onOpenChange:

const [open, setOpen] = useState(false);

<>
  <button
    style={{ anchorName: '--save-btn' } as CSSProperties}
    onMouseEnter={() => setOpen(true)}
    onMouseLeave={() => setOpen(false)}
  >
    Save
  </button>
  <Tooltip
    anchorName="--save-btn"
    open={open}
    onOpenChange={setOpen}
    content="Saved"
  />
</>;

Content Security Policy

The components inject their CSS as <style> tags. Under a strict style-src policy they pick up the page's nonce automatically, from <meta property="csp-nonce" nonce="…"> (what Vite's html.cspNonce adds) or else from any <script nonce="…">. Nothing to configure.

Browser support

Where CSS anchor positioning is missing, there is no polyfill and no extra dependency. The styled bubble is skipped and string content is surfaced through the element's native title tooltip instead.

Development

npm install
npm run dev        # Storybook + css-to-js watcher
npm run build      # type-check, build to lib/, generate + copy CSS
npm test

Each component owns a *.css file; css-to-js regenerates the matching *.css.generated.js, which the component injects at runtime.

License

MIT © Sergey Yakunin

Contemporary React Tooltip logo

About

React tooltip component with almost no JS (native popover API, anchor positioning, clip-path shape)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages