Curtain
Page transitionsA 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.
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.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser. Elsewhere: without the router the player is a normal audio element that restarts with the page.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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 />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/persist-player.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| Prop | Type | Default | Description |
|---|---|---|---|
src | string | none | Audio file. MP3 plays everywhere; keep it small (this site's 20-second loop is a 98 kB MP3). |
title | string | none | Track title. |
artist | string | none | Artist or source line. |
loop | boolean | false | Loop the track. |
name | string | 'ma-player' | Persist key. The same key on two pages pairs the players; a page without it drops the player and stops the audio. |
The bars beside the title stay still; the player is unaffected.
ClientRouterPersistPlayer 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.
Replaces: SPA global players (React context + Howler) · iframes kept alive for audio. See the migration table.
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.
Infinite scrolling strip for logos, quotes or tags in any direction.
Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown