Skip to content

Components / UI

Tooltip

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.

Live demo

Send the API keyA secret key from your dashboard, sent as a bearer token. with every request, and pass an Idempotency-KeyAny unique string. A retry with the same key returns the first response instead of acting twice. on writes.

Copy the request ID

hover, focus or tap

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

802 B raw

462 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.

CSS 6.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 125 · Firefox 147 · Safari 26; needs popover, anchor, starting-style, allow-discrete. Elsewhere: without anchor positioning the script places the tip above the trigger; without the script the tip shows on hover and focus from CSS.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Tooltip from '@moonarc/core/Tooltip';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/tooltip.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and add the JS gate to your head.

Tooltip.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>

Props

PropTypeDefaultDescription
textstringnoneThe 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.
underlinebooleantrue on a spanA 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.
idstringnonePopover id; generated when unset.

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.

Why it is built this way

Replaces: Tooltip (shadcn / Radix) · Animated Tooltip (Aceternity) · Tooltip (React Bits) · Tippy.js. See the migration table.

npx astro add moonarc
click · check · back

A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration.

every browserclick

A link that shows a small card (image, title, one line, domain) after a moment on hover or focus, above everything, flipping when there is no room.

newly · Chrome 125 · Firefox 147 · Safari 26hover

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown