EmTooltip
A description bubble that opens on hover or keyboard focus — delays, warmup and dismissal come from v0; Emerald supplies the dark, compact surface.
Usage
Three parts. EmTooltip owns the open state, the delays and the placement; EmTooltipActivator is the trigger — a native <button> by default; EmTooltipContent is the bubble, rendered in the top layer through the native popover attribute and pinned to the trigger by CSS anchor positioning.
Hovering the trigger opens the bubble after an open delay — 700ms by default — and leaving it closes after a short close delay. Keyboard focus skips the wait entirely and opens on the spot. v-model is optional: the parts coordinate through context, so an uncontrolled tooltip is just the three tags, and the model is only for the rare case where something else needs to read or drive the state.
Anatomy
<script setup lang="ts">
import {
EmTooltip,
EmTooltipActivator,
EmTooltipContent,
} from '@paper/emerald'
</script>
<template>
<EmTooltip>
<EmTooltipActivator />
<EmTooltipContent />
</EmTooltip>
</template>Composed on v0
Each part maps one-to-one onto v0’s Tooltip compound — Tooltip.Root, Tooltip.Activator, Tooltip.Content. v0 owns everything that behaves: the open and close timers, the pointer and focus wiring, the aria-describedby link, the native popover rendering and the anchor plumbing. Emerald owns only the skin — the .emerald-tooltip__content bubble, a 240px-max dark surface on the system’s smallest body step — and an unstyled .emerald-tooltip__activator class hook on the trigger. The root renders no element at all.
The timing model is the part worth understanding. Delays resolve against a region: openDelay and closeDelay fall back to region defaults of 700ms and 150ms, and after any tooltip closes there is a 300ms skip window during which the next one opens instantly — the warmup pattern that makes sweeping across a toolbar feel immediate instead of stuttering through a delay per button. Install v0’s useTooltip plugin (createTooltipPlugin) to share that region app-wide; without it each EmTooltip falls back to its own private region, so the defaults still apply and re-hovering the same trigger inside the window is instant, but separate tooltips do not warm each other up.
The open state also distinguishes how it opened: the content and activator carry data-state as closed, delayed-open or instant-open, so CSS can animate a patient hover reveal differently from an instant warmup or focus one. Emerald’s skin does not animate either today; the attributes are there to hang your own transitions on.
Examples
Props
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | boolean | false | Open state. Optional — the compound is fully functional uncontrolled |
openDelay | number | region default (700) | ms of hover before opening. Keyboard focus and the warmup skip window bypass it |
closeDelay | number | region default (150) | ms before closing after the pointer leaves |
disabled | boolean | false | Disables the tooltip. On the default activator this also sets the native disabled attribute on the trigger button itself |
interactive | boolean | false | Content stays open while the pointer is over it |
positionArea | string | 'top' | CSS position-area for the bubble |
positionTry | string | 'most-height top' | CSS position-try fallbacks |
namespace | string | 'v0:tooltip' | Which v0 Tooltip context the parts bind to. Only needed when nesting |
Parts
Every part takes namespace, defaulting to 'v0:tooltip'.
| Part | Renders | Props | Slot props |
|---|---|---|---|
EmTooltipActivator | The trigger — a native button unless as or renderless says otherwise | as (default 'button'), renderless: boolean (default false) | isOpen, isDisabled, attrs, styles |
EmTooltipContent | The bubble — a role="tooltip" div in the top layer via the native popover attribute | — | — |
The activator’s attrs bundle carries aria-describedby pointing at the bubble, data-state (closed / delayed-open / instant-open), data-disabled, the native disabled and type="button" attributes when as is 'button' — the default, renderless included — and aria-disabled when it is not, plus the pointer, focus, blur, click and Escape handlers. styles is the anchor-name declaration. In renderless mode bind both onto exactly one element.
EmTooltipContent styles the bubble through the .emerald-tooltip__content class; its default slot is plain content with no slot props. Neither part — nor the root — exposes anything through a template ref.
Accessibility
The bubble is a description, wired the way WAI-ARIA expects: the content carries role="tooltip", and the trigger carries aria-describedby pointing at it, so a screen reader announces the tooltip text after the trigger’s own name. That order matters for what you put where — the tooltip supplements the trigger’s name, it never provides one. An icon-only trigger still needs its own label; a tooltip that just repeats the visible label adds noise and should be dropped.
Triggers and dismissal
| Interaction | Behavior |
|---|---|
| Pointer enters trigger (mouse, pen) | Opens after openDelay — or instantly inside the warmup skip window |
| Pointer leaves trigger | Closes after closeDelay |
| Keyboard focus | Opens instantly, no delay — gated on :focus-visible, so a mouse click that incidentally moves focus does not open it |
| Blur | Closes |
| Click / activation | Closes — activating the trigger dismisses its tooltip |
| Escape | Closes, from the trigger or from inside interactive content |
| Touch | Never opens — touch pointers are suppressed entirely, per the WAI-ARIA APG |
The touch row is a design constraint to plan around, not a bug: a tooltip has no hover to hang off on a touch screen. Anything a touch user must be able to read cannot live only in a tooltip.
Disabled
disabled prevents the tooltip from opening, and on the default activator it goes further: the underlying v0 activator sets the native disabled attribute on its button, taking the trigger itself out of the tab order and out of pointer interaction. Which attribute you get is decided by the as prop, not by renderless — any non-'button' as receives aria-disabled instead and stays focusable, while a renderless trigger with the default as still finds the native disabled attribute in its attrs bundle. If you want a disabled tooltip on a still-working trigger, don’t reach for disabled at all: in renderless mode the tooltip only touches your element through the attrs you spread, so leaving them unbound leaves the trigger fully functional with no tooltip wiring.
Interactive mode
interactive is a pointer affordance only. Focus leaving the trigger closes the tooltip, so keyboard focus cannot travel into the content — links or buttons inside an interactive tooltip are unreachable for keyboard and switch users. Keep the content supplementary, and give any essential action a surface with real focus semantics, like EmPopover or EmDialog.