npx astro add moonarcA 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.
Components / UI
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.
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.
hover, focus or tap
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/tooltip.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
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 — 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>| 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. |
The tip fades in without the scale, after the same wait.
ClientRouterBound 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.
Replaces: Tooltip (shadcn / Radix) · Animated Tooltip (Aceternity) · Tooltip (React Bits) · Tippy.js. See the migration table.
npx astro add moonarcA 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.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown