Skip to content

Components / UI

Hover Video

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.

Live demo

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

Measured

JavaScript of its own

684 B raw

364 B gzip.

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

CSS 10.1 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 · widely available

every browser.

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 HoverVideo from '@moonarc/core/HoverVideo';
---
<HoverVideo
  src={['/work/loop.webm', '/work/loop.mp4']}
  poster="/work/loop.jpg"
  href="/work/identity/"
  title="Identity for a bakery"
  meta="Brand · 2025"
/>
Copy it into your project instead (shadcn registry)

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

terminal
npx shadcn@latest add https://moonarc.dev/r/hover-video.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 AnimatedIcons.astro.

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

Props

PropTypeDefaultDescription
srcstring | string[]noneThe 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.
posterstringnoneThe poster image, shown until it plays and after it rewinds.
hrefstringnoneWhere the card links; default the clip (the first source), so the card is always a link and keyboard focus can play it.
titlestringnoneCaption title.
metastringnoneA mono line under the title.
ratiostring'16 / 10'CSS aspect ratio of the card.
labelstring'Play preview'Accessible name of the play button.

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.

Why it is built this way

Replaces: HoverVideo (Cult UI) · VideoCard (Aceternity) · react-hover-video-player. See the migration table.

Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.

Chrome 52 · Firefox 97 · not Safariclick
Study 1Study 2Study 3

click · grows from its thumbnail · ← →

A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set.

newly · Chrome 111 · Firefox 144 · Safari 18click

click · morphs open

A poster with a breathing play button that opens the video in a dialog: a file with native controls, or an embed.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

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