# Popover (Moonarc)

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.

- Import: `import Popover from '@moonarc/core/Popover'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/popover.json`
- Tier A · category ui · trigger click
- Readout: `<Popover placement="bottom">`
- Browser support: newly (Chrome 125 · Firefox 147 · Safari 26); elsewhere: without anchor positioning it opens as a small sheet at the bottom of the viewport
- Measured cost: 0 B JS (CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/popover/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Popover id; the trigger points at it. |
| `label` | `string` | `'Open'` | Trigger label. Use the trigger slot for custom markup (give it popovertarget={id} and style="anchor-name: --ma-pop-{id}"). |
| `placement` | `string` | `'bottom'` | A position-area value: top, bottom, left, right, or two keywords such as "bottom span-right". |
| `gap` | `string` | `'0.5rem'` | Distance from the trigger. |
| `--ma-pop-max` | `CSS 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-pad` | `CSS padding (custom property)` | `0.875rem 1rem` | The popover's padding. Set it in a rule or style on the popover (a class passed to it) or an ancestor. |

## Usage

```astro
<Popover id="help" label="What is measured?" placement="bottom">
  <p>Raw bytes of JavaScript from a production build.</p>
</Popover>
```

## Reduced motion

A 150 ms crossfade, no scale.

## With ClientRouter

Native; nothing to rebind.

## Craft

- position-try-fallbacks: flip-block, flip-inline. The browser flips it when it would overflow, which is the part libraries spend most of their bytes on.
- Light dismiss, Escape and the top layer come from popover="auto"; none of it is re-implemented.
- Enter on the preset curve, exit in 150 ms; the backdrop is transparent because a popover is not a modal.

## Replaces

- Popover (Radix / shadcn)
- Floating UI for simple popovers
- HoverCard

## Source

```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>

```
