Skip to content

Components / UI

Popover

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out. Zero JavaScript: the popover attribute, anchor positioning and @starting-style do all of it.

Live demo

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

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

Measured

JavaScript of its own

0 B

CSS 5.2 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 it opens as a small sheet at the bottom of the viewport.

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 Popover from '@moonarc/core/Popover';
---
<Popover id="help" label="What is measured?" placement="bottom">
  <p>Raw bytes of JavaScript from a production build.</p>
</Popover>
Copy it into your project instead (shadcn registry)

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

terminal
npx shadcn@latest add https://moonarc.dev/r/popover.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; it imports nothing.

Popover.astro
---
/**
 * Popover — a popover anchored to its trigger, zero JS.
 *
 * The popover attribute gives open/close, light dismiss, Escape and the top
 * layer. Anchor positioning places it beside the trigger and flips it when
 * there is no room; @starting-style animates it in and allow-discrete out.
 * Browsers without anchor positioning show it centred at the bottom of the
 * viewport, still fully functional.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Popover id; the trigger points at it. */
  id: string;
  /** Trigger label; or use the `trigger` slot. */
  label?: string;
  /** position-area value: top, bottom, left, right, or any two-keyword form. */
  placement?: string;
  /** Gap from the trigger, any CSS length. */
  gap?: string;
}

const { id, label = 'Open', placement = 'bottom', gap = '0.5rem', class: className, style, ...rest } = Astro.props;
const anchor = `--ma-pop-${id}`;
const inline = [`position-anchor:${anchor}`, `--ma-pop-area:${placement}`, `--ma-pop-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const triggerAttrs = { popovertarget: id, style: `anchor-name:${anchor}` };
const popAttrs = { popover: 'auto' };
---

{Astro.slots.has('trigger') ? <slot name="trigger" /> : <button type="button" class="ma-popover__trigger" {...triggerAttrs}>{label}</button>}
<div id={id} class:list={['ma-popover', className]} style={inline} {...popAttrs} {...rest}>
  <slot />
</div>

<style is:global>
  @layer components {
    :where(.ma-popover) {
      position: fixed;
      inset: auto;
      position-area: var(--ma-pop-area, bottom);
      position-try-fallbacks: flip-block, flip-inline;
      margin: var(--ma-pop-gap, 0.5rem);
      /* two hooks for a component that builds on Popover (MegaMenu): its own width and padding reach the popover even
         where this rule comes later in the page's CSS than its own */
      max-width: var(--ma-pop-max, min(20rem, calc(100vw - 2rem)));
      border: 1px solid var(--ma-edge);
      border-radius: 0.75rem;
      padding: var(--ma-pop-pad, 0.875rem 1rem);
      background: var(--ma-panel);
      color: inherit;
      box-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.35);
      opacity: 0;
      scale: 0.96;
      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;
    }
    :where(.ma-popover:popover-open) {
      opacity: 1;
      scale: 1;
      transition-duration: var(--ma-duration);
      transition-timing-function: var(--ma-ease);
    }
    @starting-style {
      :where(.ma-popover:popover-open) {
        opacity: 0;
        scale: 0.96;
      }
    }
    :where(.ma-popover)::backdrop {
      background: transparent;
    }
    :where(.ma-popover__trigger) {
      cursor: pointer;
    }
    /* no anchor positioning: a small sheet at the bottom of the viewport */
    @supports not (position-area: bottom) {
      :where(.ma-popover) {
        inset: auto 0 1.5rem;
        margin-inline: auto;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-popover),
      :where(.ma-popover:popover-open) {
        scale: 1;
        transition-duration: var(--ma-duration-fast);
        transition-timing-function: linear;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
idstringnonePopover id; the trigger points at it.
labelstring'Open'Trigger label. Use the trigger slot for custom markup (give it popovertarget={id} and style="anchor-name: --ma-pop-{id}").
placementstring'bottom'A position-area value: top, bottom, left, right, or two keywords such as "bottom span-right".
gapstring'0.5rem'Distance from the trigger.
--ma-pop-maxCSS length (custom property)min(20rem, calc(100vw - 2rem))The popover's max-width. Set it in a rule or style on the popover (a class passed to it) or an ancestor, for example for a menu panel wider than 20 rem.
--ma-pop-padCSS padding (custom property)0.875rem 1remThe popover's padding. Set it in a rule or style on the popover (a class passed to it) or an ancestor.

Reduced motion

A 150 ms crossfade, no scale.

With ClientRouter

Native; nothing to rebind.

Why it is built this way

Replaces: Popover (Radix / shadcn) · Floating UI for simple popovers · HoverCard. See the migration table.

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces.

newly · Chrome 131 · Firefox 143 · Safari 18.4click

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

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