# Tooltip (Moonarc)

A short tip anchored to a word, a code or a button: it shows after a moment on hover and at once on focus or a tap, stays while the pointer moves onto it, flips when there is no room, and Escape hides it. The tip is a manual popover in the top layer, so a table, a scrolling box or a sticky header never covers or clips it, and it is the trigger's description, so a screen reader reads it with the trigger. The script only shows and hides it; the wait is a CSS transition-delay. A span trigger is focusable and carries a dotted underline.

- Import: `import Tooltip from '@moonarc/core/Tooltip'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/tooltip.json`
- Tier B · category ui · trigger hover, click
- Readout: `<Tooltip text="…">`
- Browser support: newly (Chrome 125 · Firefox 147 · Safari 26); elsewhere: without anchor positioning the script places the tip above the trigger; without the script the tip shows on hover and focus from CSS
- Measured cost: 802 B raw JS · 462 B gzip · + runtime (with dependencies 2.5 kB raw; CSS 6.7 kB raw)
- Page: https://moonarc.dev/components/tooltip/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The tip. Slot "tip" wins and may hold a little markup (a code, a strong). |
| `as` | `'span' | 'button'` | `'span'` | The trigger's element. A span is made focusable (tabindex 0) for a word or a code; a button is a button, for an icon that needs a name. |
| `underline` | `boolean` | `true on a span` | A dotted underline under the trigger, the usual sign that a word has more to say. |
| `placement` | `'top' | 'bottom' | 'left' | 'right'` | `'top'` | Where the tip sits; it flips to the other side when there is no room. |
| `delay` | `'fast' | 'base' | 'slow'` | `'fast'` | The wait before the tip shows on hover: the fast, active-preset or slow duration token. Focus shows it at once. |
| `id` | `string` | none | Popover id; generated when unset. |

## Usage

```astro
<p>
  Every request needs an
  <Tooltip text="A secret key from your dashboard, sent as a bearer token.">API key</Tooltip>.
</p>

<Tooltip as="button" text="Copy the request ID" placement="bottom" aria-label="Copy">
  <svg aria-hidden="true">…</svg>
</Tooltip>

<Tooltip>
  <code>string</code>
  <Fragment slot="tip">At most 255 characters. Unique per account.</Fragment>
</Tooltip>
```

## Reduced motion

The tip fades in without the scale, after the same wait.

## With ClientRouter

Bound through the shared runtime; the tip is hidden before the swap so nothing stays in the top layer, and the document Escape listener goes with the binding.

## Craft

- The hover intent is a transition-delay on a duration token, not a timer: the tip enters the top layer at once but stays transparent and visibility: hidden through the wait, so a pointer crossing the trigger never raises it or lands on it.
- Focus clears the delay. A keyboard reader asked for the tip by moving to it; a pointer passing over did not.
- The gap between trigger and tip is the tip's own ::before, and the pointer is watched on the wrapper that holds both, so moving onto the tip to read it keeps it open. Escape hides it wherever the focus is. Together that is WCAG 1.4.13: hoverable, dismissible, persistent.
- A manual popover in the top layer, placed with anchor positioning and position-try-fallbacks: inside a table cell or a scrolling list it is neither clipped nor covered, and it flips below the trigger near the top of the viewport.
- role="tooltip" with aria-describedby on the trigger, so a screen reader hears the tip as the trigger's description without any hover, and the tip holds nothing interactive.
- On touch, a tap shows it (a span with tabindex 0 takes focus, and the click shows it where it does not) and a tap anywhere else hides it.

## Replaces

- Tooltip (shadcn / Radix)
- Animated Tooltip (Aceternity)
- Tooltip (React Bits)
- Tippy.js

## Source

```astro
---
/**
 * Tooltip — a short tip anchored to its trigger, shown after a moment on hover and at once on focus, kept while the
 * pointer moves onto it, and hidden with Escape wherever the pointer is (WCAG 1.4.13). The tip is a manual popover in
 * the top layer, so a table or a scrolling box never clips it, placed by anchor positioning and flipped when there is
 * no room; the hover intent is a CSS transition-delay, so no timer runs. The script only shows and hides it. The tip is
 * the trigger's description (aria-describedby), so a screen reader reads it with the trigger with or without the script.
 * A tap focuses the trigger and shows the tip; a tap elsewhere hides it.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The tip. Slot "tip" wins. */
  text?: string;
  /** The trigger's element: a focusable span around a word or a code, or a button. */
  as?: 'span' | 'button';
  /** A dotted underline on a span trigger, the usual sign that a word has more to say. */
  underline?: boolean;
  /** position-area of the tip: top, bottom, left or right of the trigger. */
  placement?: 'top' | 'bottom' | 'left' | 'right';
  /** The wait before the tip shows on hover, as a duration token; focus shows it at once. */
  delay?: 'fast' | 'base' | 'slow';
  /** Popover id; default generated. */
  id?: string;
}

const { text, as = 'span', underline = as === 'span', placement = 'top', delay = 'fast', id = `ma-tip-${Math.random().toString(36).slice(2, 8)}`, class: className, style, ...rest } = Astro.props;
const anchor = `--${id}`;
const area = { top: 'top', bottom: 'bottom', left: 'left', right: 'right' }[placement];
const Tag = as;
---

<span class:list={['ma-tip', className]} data-ma-tip data-delay={delay} data-placement={placement} style={typeof style === 'string' ? style : undefined}>
  <Tag class="ma-tip__trigger" aria-describedby={id} data-underline={underline ? '' : undefined} tabindex={as === 'span' ? 0 : undefined} type={as === 'button' ? 'button' : undefined} style={`anchor-name:${anchor}`} {...rest}><slot /></Tag>
  <span id={id} class="ma-tip__tip" popover="manual" role="tooltip" style={`position-anchor:${anchor};--ma-tip-area:${area}`}>{Astro.slots.has('tip') ? <slot name="tip" /> : text}</span>
</span>

<style is:global>
  @layer components {
    :where(.ma-tip) {
      --ma-tip-delay: var(--ma-duration-fast);
    }
    :where(.ma-tip[data-delay='base']) {
      --ma-tip-delay: var(--ma-duration);
    }
    :where(.ma-tip[data-delay='slow']) {
      --ma-tip-delay: var(--ma-duration-slow);
    }
    :where(.ma-tip__trigger) {
      font: inherit;
      color: inherit;
      cursor: help;
    }
    :where(button.ma-tip__trigger) {
      cursor: pointer;
    }
    :where(.ma-tip__trigger[data-underline]) {
      text-decoration: underline dotted;
      text-decoration-color: color-mix(in oklab, currentColor 55%, transparent);
      text-decoration-thickness: 1.5px;
      text-underline-offset: 0.22em;
    }
    :where(.ma-tip__trigger:focus-visible) {
      outline: 2px solid var(--ring, currentColor);
      outline-offset: 2px;
      border-radius: 3px;
    }
    /* the tip: top layer, anchored, its entrance waiting on the delay token (the hover intent is CSS); focus clears the
       delay below, so a keyboard reader gets the tip at once */
    :where(.ma-tip__tip) {
      position: fixed;
      inset: auto;
      position-area: var(--ma-tip-area, top);
      position-try-fallbacks: flip-block, flip-inline;
      /* centred on the trigger, however much wider than it the tip is */
      justify-self: anchor-center;
      margin: 0.45rem;
      max-inline-size: min(18rem, calc(100vw - 2rem));
      padding: 0.4rem 0.6rem;
      border: 0;
      border-radius: var(--ma-radius-md, 0.5rem);
      background: var(--ma-tip-bg, var(--foreground, var(--ma-ink)));
      color: var(--ma-tip-fg, var(--background, var(--ma-panel)));
      font-family: var(--ma-font-body, inherit);
      font-size: 0.8125rem;
      font-weight: 450;
      line-height: 1.4;
      text-align: start;
      white-space: normal;
      overflow: visible;
      box-shadow: 0 10px 28px -14px rgb(0 0 0 / 0.5);
      pointer-events: none;
      opacity: 0;
      scale: 0.97;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration-fast) var(--ma-ease-out),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete,
        visibility var(--ma-duration-fast);
    }
    :where(.ma-tip[data-placement='left'] .ma-tip__tip, .ma-tip[data-placement='right'] .ma-tip__tip) {
      justify-self: auto;
      align-self: anchor-center;
    }
    :where(.ma-tip__tip:popover-open) {
      pointer-events: auto;
      opacity: 1;
      scale: 1;
      transition-duration: var(--ma-duration-fast), var(--ma-duration-fast), 0s, 0s, 0s;
      transition-timing-function: var(--ma-ease-out), var(--ma-ease-out), linear, linear, linear;
      transition-delay: var(--ma-tip-delay), var(--ma-tip-delay), 0s, 0s, var(--ma-tip-delay);
    }
    :where(.ma-tip:focus-within .ma-tip__tip:popover-open) {
      transition-delay: 0s;
    }
    @starting-style {
      :where(.ma-tip__tip:popover-open) {
        opacity: 0;
        scale: 0.97;
        visibility: hidden;
      }
    }
    :where(.ma-tip__tip)::backdrop {
      background: transparent;
    }
    /* the gap between the trigger and the tip belongs to the tip, so the pointer can cross onto it (WCAG 1.4.13) */
    :where(.ma-tip__tip)::before {
      content: '';
      position: absolute;
      z-index: -1;
      inset: -0.5rem;
    }
    /* no anchor positioning: the script hands the trigger's box over as two lengths, and the tip sits above it */
    @supports not (position-area: top) {
      :where(.ma-tip__tip) {
        left: var(--ma-tip-x, 0px);
        top: var(--ma-tip-y, 0px);
        translate: -50% calc(-100% - 0.45rem);
        margin: 0;
      }
    }
    /* no script: the tip shows on hover and focus from CSS, in the flow's top layer stand-in */
    :where(html:not([data-ma-js]) .ma-tip:is(:hover, :focus-within) .ma-tip__tip) {
      display: block;
      opacity: 1;
      scale: 1;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-tip__tip),
      :where(.ma-tip__tip:popover-open) {
        scale: 1;
        transition-timing-function: linear;
      }
    }
    @media (forced-colors: active) {
      :where(.ma-tip__tip) {
        border: 1px solid CanvasText;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-tip]', (el, { signal }) => {
    const trigger = el.querySelector<HTMLElement>('.ma-tip__trigger')!;
    const tip = el.querySelector<HTMLElement>('[popover]')!;
    const place = !CSS.supports('position-area: top');
    const show = () => {
      if (place) {
        const r = trigger.getBoundingClientRect();
        tip.style.setProperty('--ma-tip-x', `${r.left + r.width / 2}px`);
        tip.style.setProperty('--ma-tip-y', `${r.top}px`);
      }
      if (!tip.matches(':popover-open')) tip.showPopover();
    };
    const hide = () => tip.matches(':popover-open') && tip.hidePopover();
    const on = (t: string, f: (e: Event) => void, target: EventTarget = trigger) => target.addEventListener(t, f, { signal });
    // the pointer on the wrapper, not the trigger: the tip is the wrapper's child, so moving onto it leaves neither
    on('pointerenter', (e) => (e as PointerEvent).pointerType === 'mouse' && show(), el);
    on('pointerleave', (e) => (e as PointerEvent).pointerType === 'mouse' && trigger !== document.activeElement && hide(), el);
    on('focus', show);
    on('blur', hide);
    // a tap focuses a button but not every span: show on the tap too, and a tap elsewhere blurs it
    on('click', show);
    // Escape hides it wherever the focus is: a pointer resting on the trigger has no focus on it
    on('keydown', (e) => (e as KeyboardEvent).key === 'Escape' && hide(), document);
    // placed from the trigger's box, the tip would stay behind on a scroll; anchored, it follows
    if (place) window.addEventListener('scroll', hide, { signal, capture: true, passive: true });
    signal.addEventListener('abort', hide);
  });
</script>

```
