# Hover Video (Moonarc)

A poster card that plays a muted clip while the pointer or focus is on it and rewinds when it leaves. The card is a link, the video decoration behind it with preload="none", so nothing loads until a hover; coarse pointers get a play/pause button, reduced motion never autoplays. The script is play(), pause() and a rewind.

- Import: `import HoverVideo from '@moonarc/core/HoverVideo'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/hover-video.json`
- Tier B · category ui · trigger hover
- Readout: `<HoverVideo poster="…">`
- Browser support: widely (every browser)
- Measured cost: 684 B raw JS · 364 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 10.1 kB raw)
- Page: https://moonarc.dev/components/hover-video/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `src` | `string | string[]` | none | The clip; short and silent (it is muted anyway). An array is several formats (webm first, then mp4), and the browser plays the first it can. |
| `poster` | `string` | none | The poster image, shown until it plays and after it rewinds. |
| `href` | `string` | none | Where the card links; default the clip (the first source), so the card is always a link and keyboard focus can play it. |
| `title` | `string` | none | Caption title. |
| `meta` | `string` | none | A mono line under the title. |
| `ratio` | `string` | `'16 / 10'` | CSS aspect ratio of the card. |
| `label` | `string` | `'Play preview'` | Accessible name of the play button. |

## Usage

```astro
<HoverVideo
  src={['/work/loop.webm', '/work/loop.mp4']}
  poster="/work/loop.jpg"
  href="/work/identity/"
  title="Identity for a bakery"
  meta="Brand · 2025"
/>
```

## Reduced motion

Never autoplays: the poster stands, the play/pause button appears and is the only way to play. Read live, so the page's switch applies on the next hover.

## With ClientRouter

Bound through the shared runtime; a playing clip is paused before the swap.

## Craft

- preload="none" is the budget: a grid of twelve cards downloads nothing until one is hovered, and a poster is the whole cost of the page.
- The card is the link and the video is aria-hidden decoration: the title is what the link says, the clip is what the eye gets.
- Leaving rewinds to the poster, so every hover starts the clip from its first frame and the grid never shows twelve clips paused at random moments.
- The video element scales 4 % on hover on the gentle duration, which lifts the card without a shadow. Poster and clip are the same element, so the first frame replaces the poster in place, with no cross-fade.
- Where a hover cannot happen (touch) or must not (reduced motion) the same script wires a real button with aria-pressed, and AnimatedIcons turns its triangle into the pause bars. The button shows only once the script has bound, so without JavaScript nobody is offered a control that does nothing.

## Replaces

- HoverVideo (Cult UI)
- VideoCard (Aceternity)
- react-hover-video-player

## Source

```astro
---
/**
 * HoverVideo — a poster card that plays a muted clip while the pointer (or
 * focus) is on it and rewinds when it leaves. The card is a link; the video
 * is decoration behind it: muted, playsinline, loop, preload="none", so the
 * page pays nothing until a hover. On coarse pointers a play/pause button
 * appears instead of the hover; under reduced motion it never autoplays and
 * the same button is the only way to play. Without JavaScript the poster
 * and the link stand, and the button (which would do nothing) is not shown.
 */
import type { HTMLAttributes } from 'astro/types';
import AnimatedIcons from './AnimatedIcons.astro';

interface Props extends HTMLAttributes<'figure'> {
  /** The clip; short, muted. Several formats (webm, mp4) as an array — the browser plays the first it can. */
  src: string | string[];
  /** Poster image, shown until it plays and after it rewinds. */
  poster: string;
  /** Where the card goes; default the clip itself. */
  href?: string;
  title?: string;
  /** A line under the title. */
  meta?: string;
  /** CSS aspect ratio. */
  ratio?: string;
  /** Accessible name of the play button. */
  label?: string;
}

const { src, poster, href: link, title, meta, ratio = '16 / 10', label = 'Play preview', class: className, style, ...rest } = Astro.props;
const sources = Array.isArray(src) ? src : [src];
// the card is always a link, so it takes keyboard focus (which plays the clip): without href it opens the clip itself
const href = link ?? sources[0];
const type = (u: string) => (/\.webm(\?|$)/.test(u) ? 'video/webm' : /\.mp4(\?|$)/.test(u) ? 'video/mp4' : undefined);
const inline = [`--ma-hvideo-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<figure class:list={['ma-hvideo', className]} data-ma-hvideo style={inline} {...rest}>
  <video class="ma-hvideo__video" poster={poster} muted playsinline loop preload="none" tabindex="-1" aria-hidden="true">
    {sources.map((u) => <source src={u} type={type(u)} />)}
  </video>
  <button type="button" class="ma-hvideo__play" aria-label={label} aria-pressed="false">
    <AnimatedIcons icon="play" size="1.1em" />
  </button>
  <figcaption class="ma-hvideo__caption">
    <a href={href} class="ma-hvideo__link">
      {title && <span class="ma-hvideo__title">{title}</span>}
      {meta && <span class="ma-hvideo__meta">{meta}</span>}
      <slot />
    </a>
  </figcaption>
</figure>

<style is:global>
  @layer components {
    :where(.ma-hvideo) {
      position: relative;
      margin: 0;
      aspect-ratio: var(--ma-hvideo-ratio, 16 / 10);
      border: 1px solid var(--ma-edge);
      border-radius: 1rem;
      overflow: clip;
      background: var(--ma-ink);
      color: #fff;
      isolation: isolate;
    }
    :where(.ma-hvideo__video) {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      scale: 1;
      transition: scale var(--ma-dur-gentle) var(--ma-ease);
    }
    :where(.ma-hvideo:is(:hover, :focus-within) .ma-hvideo__video) {
      scale: 1.04;
    }
    /* the card is the link: the caption's anchor stretches over the whole figure, the play button sits above it */
    :where(.ma-hvideo__caption) {
      position: absolute;
      inset: auto 0 0;
      padding: 2.5rem 1rem 0.875rem;
      background: linear-gradient(transparent, rgb(0 0 0 / 0.6));
    }
    :where(.ma-hvideo__link) {
      display: grid;
      align-content: center;
      min-block-size: 24px;
      gap: 0.15rem;
      color: inherit;
      text-decoration: none;
    }
    :where(.ma-hvideo__link)::after {
      content: '';
      position: absolute;
      inset: 0;
    }
    :where(.ma-hvideo__title) {
      font-size: 0.9375rem;
      font-weight: 600;
      letter-spacing: -0.01em;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    :where(.ma-hvideo__meta) {
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      opacity: 0.75;
    }
    :where(.ma-hvideo:has(.ma-hvideo__link:focus-visible)) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    /* the play button: only where a hover cannot happen, or must not */
    :where(.ma-hvideo__play) {
      position: absolute;
      top: 0.75rem;
      right: 0.75rem;
      z-index: 1;
      display: none;
      place-items: center;
      width: 2.5rem;
      height: 2.5rem;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: var(--ma-panel);
      color: var(--ma-ink);
      font-size: 1rem;
      cursor: pointer;
      transition: scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-hvideo__play:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    /* data-live: the script bound, so the button does something */
    @media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
      :where(.ma-hvideo[data-live] .ma-hvideo__play) {
        display: grid;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-hvideo__video),
      :where(.ma-hvideo:is(:hover, :focus-within) .ma-hvideo__video),
      :where(.ma-hvideo__play) {
        scale: 1;
        transition: none;
      }
    }
  }
</style>

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

  onMount<HTMLElement>('[data-ma-hvideo]', (el, { signal }) => {
    const v = el.querySelector('video')!;
    const btn = el.querySelector('button')!;
    const play = () => v.play().then(() => btn.setAttribute('aria-pressed', 'true')).catch(() => {});
    const stop = () => {
      v.pause();
      v.currentTime = 0;
      btn.setAttribute('aria-pressed', 'false');
    };
    btn.addEventListener('click', () => (v.paused ? play() : stop()), { signal });
    signal.addEventListener('abort', () => v.pause());
    el.dataset.live = '';
    if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    // hover and focus play; reduced motion is read at each hover so the page's switch applies at once
    const hover = () => !prefersReducedMotion() && play();
    el.addEventListener('pointerenter', hover, { signal });
    el.addEventListener('focusin', hover, { signal });
    el.addEventListener('pointerleave', stop, { signal });
    el.addEventListener('focusout', stop, { signal });
  });
</script>

```
