Skip to content

Components / Page transitions

Persist Player

An audio player that keeps playing while you navigate: the same <audio> element is carried into the next page by Astro's router, so position, volume and playback never restart. Native controls, zero JavaScript of its own. Put it in the layout; every page gets the one that is already playing.

Live demo

Ambient loopthe same element rides into the next page

Press play, then navigate: position, volume and playback carry over.

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

Measured

JavaScript of its own

0 B

CSS 6.0 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. Elsewhere: without the router the player is a normal audio element that restarts with the page.

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 PersistPlayer from '@moonarc/core/PersistPlayer';
// in the layout, so every page has it
---
<PersistPlayer src="/audio/loop.mp3" title="Ambient loop" artist="synthesised, CC0" loop />
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/persist-player.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.

PersistPlayer.astro
---
/**
 * PersistPlayer — an audio player that keeps playing across page
 * navigations. The native <audio controls> sits in a wrapper marked
 * transition:persist, so Astro's router moves the very same element into
 * the next page instead of replacing it: playback, position and volume
 * continue. Zero JS of its own; the controls are the browser's. Where
 * :playing exists (Safari 15.4, Firefox 150; not Chrome) the bars move
 * while the track plays.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Audio file. */
  src: string;
  /** Track title. */
  title?: string;
  /** Artist or source line. */
  artist?: string;
  /** Loop the track. */
  loop?: boolean;
  /** Persist key; the same key on two pages pairs the players. */
  name?: string;
}

const { src, title, artist, loop = false, name = 'ma-player', class: className, ...rest } = Astro.props;
---

<div class:list={['ma-player', className]} transition:persist={name} {...rest}>
  <span class="ma-player__bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
  {(title || artist) && (
    <span class="ma-player__meta">
      {title && <b class="ma-player__title">{title}</b>}
      {artist && <span class="ma-player__artist">{artist}</span>}
    </span>
  )}
  <audio class="ma-player__audio" controls preload="metadata" src={src} loop={loop}></audio>
</div>

<style is:global>
  @layer components {
    :where(.ma-player) {
      display: grid;
      grid-template-columns: auto 1fr;
      align-items: center;
      gap: 0.6em 0.9em;
      padding: 0.85em 1em;
      border: 1px solid var(--ma-edge);
      border-radius: 0.75em;
      background: var(--ma-panel);
      color: var(--ma-ink);
    }
    :where(.ma-player__bars) {
      display: flex;
      align-items: flex-end;
      gap: 2px;
      width: 1.1em;
      height: 1.1em;
    }
    :where(.ma-player__bars > i) {
      flex: 1;
      height: 100%;
      border-radius: 1px;
      background: currentColor;
      transform-origin: 50% 100%;
      scale: 1 0.35;
      /* the bar's index, named for the player and set on every bar (0 here): a page's own --ma-i must not delay the first */
      --ma-player-i: 0;
      animation: ma-player-bar var(--ma-dur-ambient) var(--ma-ease-in-out) calc(var(--ma-player-i) * var(--ma-stagger-relaxed)) infinite alternate;
      animation-play-state: paused;
    }
    :where(.ma-player__bars > i:nth-child(2)) {
      --ma-player-i: 1;
      scale: 1 0.7;
    }
    :where(.ma-player__bars > i:nth-child(3)) {
      --ma-player-i: 2;
      scale: 1 0.5;
    }
    :where(.ma-player__bars > i:nth-child(4)) {
      --ma-player-i: 3;
      scale: 1 0.85;
    }
    /* :playing exists in WebKit and Firefox (150), not in Chrome; :where() forgives the selector there and the bars stay still */
    :where(.ma-player:has(.ma-player__audio:playing) .ma-player__bars > i) {
      animation-play-state: running;
    }
    :where(.ma-player__meta) {
      display: grid;
      min-width: 0;
      line-height: 1.3;
    }
    :where(.ma-player__title) {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    :where(.ma-player__artist) {
      font-size: 0.8125em;
      opacity: var(--ma-dim, 0.7);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    :where(.ma-player__audio) {
      grid-column: 1 / -1;
      width: 100%;
      min-width: 0;
      height: 2.25em;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-player__bars > i) {
        animation: none;
      }
    }
  }

  @keyframes ma-player-bar {
    from {
      scale: 1 0.25;
    }
    to {
      scale: 1 1;
    }
  }
</style>

Props

PropTypeDefaultDescription
srcstringnoneAudio file. MP3 plays everywhere; keep it small (this site's 20-second loop is a 98 kB MP3).
titlestringnoneTrack title.
artiststringnoneArtist or source line.
loopbooleanfalseLoop the track.
namestring'ma-player'Persist key. The same key on two pages pairs the players; a page without it drops the player and stops the audio.

Reduced motion

The bars beside the title stay still; the player is unaffected.

With ClientRouter

PersistPlayer is built on the router: transition:persist moves the existing element into the new document during the swap, so the media element, its buffer and its play state are the same object before and after.

Why it is built this way

Replaces: SPA global players (React context + Howler) · iframes kept alive for audio. See the migration table.

page one

Iris.

page two

From the click.

Try the real swap →

Curtain

Page transitions

A full-viewport wipe between pages: the next page opens through an iris from where you clicked, or through blinds, a shutter, doors, pixels, staggered columns, a diagonal, or a title panel that carries the next page's name.

newly · Chrome 120 · Firefox 144 · Safari 18click

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover

page one

Hero.

page two

Hero.

Try the real swap →

Page Transition

Page transitions

Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.

newly · Chrome 111 · Firefox 144 · Safari 18click

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