Skip to content

Documentation

neelam-ui

Search documentation

Getting Started
Forms
Overlays
Navigation
Data Display
Layout
AI & Chat
Blocks
GitHub repository

Tooltip

A short, supplementary label shown near its trigger on hover or focus — a transient hint alongside what you are already doing, not something you interact with.

Your plan includes 500 build minutes per cycle.

Usage#

import { Tooltip, TooltipContent, TooltipTrigger } from "neelam-ui";
 
<Tooltip>
  <TooltipTrigger>per cycle</TooltipTrigger>
  <TooltipContent>Resets on the 1st of each month.</TooltipContent>
</Tooltip>

Sides#

side picks which edge of the trigger the bubble sits on — "top" by default. It is a preference, not a guarantee: the position is clamped to the viewport, so a tooltip near an edge shifts rather than getting cut off.

toprightbottomleft

Timing#

Hover waits delayDuration milliseconds (300 by default). Focus shows immediately. That asymmetry is deliberate: a keyboard user has committed to the element by focusing it, whereas a pointer sweeping across the screen has not, and the hover delay exists to absorb exactly that.

<Tooltip delayDuration={0}>…</Tooltip>

The trigger is a span#

TooltipTrigger renders <span tabIndex={0}>, not a <button>. A tooltip often annotates something that is not actionable — a truncated label, a status icon — and claiming button semantics for content with no click behaviour would misdescribe it. The tabIndex is what makes it reachable by keyboard at all, which WAI-ARIA requires: a tooltip must not be mouse-only.

Never put essential information in a tooltip

Tooltips are unavailable on touch, where there is no hover. Anything the user must have to complete the task belongs in the page — a description under a field, not a hint beside it.

Annotating something already interactive#

Putting a real <a> or <button> inside TooltipTrigger's <span> seems like the obvious way to add a tooltip to an existing control — but it creates two separate focusable elements for what is really one control: Tab would stop on the outer <span tabIndex={0}>, then stop again on the <button> inside it.

Pass asChild instead. It merges the trigger's hover/focus handling and aria-describedby directly onto your single child element rather than wrapping it, so that element stays the one and only focusable node it always was:

<Tooltip>
  <TooltipTrigger asChild>
    <a href="/settings" aria-label="Settings">
      <SettingsIcon />
    </a>
  </TooltipTrigger>
  <TooltipContent>Settings</TooltipContent>
</Tooltip>

This is exactly how Sidebar's icon-collapsed SidebarMenuButton and Toolbar's ToolbarButton attach their own tooltips.

The child must forward its own ref

asChild merges this trigger's ref with the child's — pass a real DOM element or a component built with forwardRef, the same requirement any asChild-style API has.

Why it portals#

TooltipContent renders into document.body. TooltipTrigger is inline so a tooltip can annotate a word in running text — but the bubble is a <div>, and a <div> inside a <p> is invalid HTML no matter how it is positioned. The portal fixes that where it can actually be fixed: where the element really lives in the DOM.

It is also popover="manual" rather than "auto". Top-layer rendering still comes from the browser — so a tooltip inside a scrolling card is not clipped — but light dismissal does not, because a tooltip closes on mouse-leave, blur, Escape, or a scroll, none of which "auto"'s outside-click handling is the right mechanism for.

Keyboard#

Keyboard shortcuts
KeyBehaviour
TabMoves focus to the trigger, which shows the tooltip immediately.
EscapeDismisses the tooltip, including one opened by hover while focus is elsewhere.

Accessibility#

  • The bubble is role="tooltip", and the trigger points at it with aria-describedby — so it is announced as a description of the trigger rather than replacing its name.
  • Focus shows the tooltip with no delay, satisfying the requirement that it be keyboard-reachable rather than hover-only.
  • Escape dismisses it (WCAG 1.4.13, Content on Hover or Focus), even when the tooltip was opened by the pointer and focus is somewhere else entirely.
  • The arrow is aria-hidden; it is decoration, not content.
  • The tooltip never takes focus itself, so it cannot become a keyboard trap.

API reference#

Tooltip#

Props for Tooltip
PropTypeDefault
defaultOpenbooleanfalse
delayDuration

Milliseconds to wait before showing on hover. Focus shows immediately — see `DECISIONS.md`. Defaults to `300`.

number300
onOpenChange((open: boolean) => void)
openboolean
side

Which side of the trigger to show on. Defaults to `"top"`.

enumtop

TooltipTrigger#

Renders a <span tabIndex={0}> and accepts every native <span> attribute; aria-describedby is wired to the bubble for you. Its one prop of its own is asChild (boolean, default false) — see above for what it does and when to reach for it.

TooltipContent#

Adds no props of its own beyond the native <div> attributes. It is positioned and portalled for you; className styles the bubble.