Play/Pause Morph
A graphical button that morphs smoothly between a play triangle and pause bars, with controlled and uncontrolled modes and an optional progress ring.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add play-pause-morph.enPlay/Pause Morph is a React play/pause toggle button built with Motion and SVG, styled with Tailwind CSS. The icon morphs between the two shapes by interpolating SVG path points, and the control itself is a native <button>.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PlayPauseMorph/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- The main control of a custom music or podcast player
- A play overlay on video cards or cover art
- Pause controls for carousels and autoplaying slideshows
- Per-row play buttons in voice message or audiobook chapter lists
Installation
npx webberui@latest add https://webberui.com/r/play-pause-morph.en.jsonOr, once registries are configured in components.json, install it as @webberui/play-pause-morph.
Usage
import { PlayPauseMorph } from "@/components/ui/play-pause-morph";
<PlayPauseMorph defaultPlaying={false} onPlayingChange={(p) => console.log(p)} />Controlled mode and the progress ring
const [playing, setPlaying] = React.useState(false);
<PlayPauseMorph
size="lg"
playing={playing}
onPlayingChange={setPlaying}
progress={0.42}
/>Passing playing enters controlled mode: the built-in state is disabled and the toggle is entirely driven by your state. Passing progress (0–1) adds a progress ring around the outside of the button.
Examples
Wire it to <audio> with a progress ring
Sync the controlled state from the <audio> element's play/pause events, and convert timeupdate into a 0–1 progress.
"use client";
import * as React from "react";
import { PlayPauseMorph } from "@/components/ui/play-pause-morph";
export function EpisodeControl({ src }: { src: string }) {
const audioRef = React.useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = React.useState(false);
const [progress, setProgress] = React.useState(0);
return (
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={src}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => {
const el = e.currentTarget;
setProgress(el.duration ? el.currentTime / el.duration : 0);
}}
/>
<PlayPauseMorph
size="lg"
playing={playing}
progress={progress}
onPlayingChange={(next) => {
if (next) void audioRef.current?.play();
else audioRef.current?.pause();
}}
/>
</div>
);
}Here playing only follows the <audio> element's real state, so if the browser blocks playback the button does not wrongly show as playing.
Check sign-in before playing
Calling preventDefault() in onClick cancels that toggle, and onPlayingChange does not fire.
<PlayPauseMorph
onClick={(e) => {
if (!isSignedIn) {
e.preventDefault();
openSignInDialog();
}
}}
onPlayingChange={(next) => (next ? player.play() : player.pause())}
playLabel="Play preview"
pauseLabel="Pause preview"
/>Custom colours
className is merged with the variant styles through cn, so it can override the background. The button carries data-state="playing" or "paused", which Tailwind data variants can target.
<PlayPauseMorph
variant="solid"
className="bg-emerald-600 text-white hover:bg-emerald-500 data-[state=playing]:bg-rose-600"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
playing | boolean | — | Controlled playing state; when provided, it is driven from outside |
defaultPlaying | boolean | false | Initial playing state in uncontrolled mode |
onPlayingChange | (playing: boolean) => void | — | Fires on toggle, returning the state after the toggle |
variant | "solid" | "soft" | "ghost" | "solid" | Visual variant |
size | "sm" | "md" | "lg" | "md" | Size (32 / 40 / 48px) |
progress | number | — | Optional progress from 0–1; when provided, the outer progress ring is shown |
playLabel | string | "Play" | aria-label while paused (pressing it will play) |
pauseLabel | string | "Pause" | aria-label while playing (pressing it will pause) |
All other native <button> attributes (disabled, onClick, className, and so on) are forwarded. Calling preventDefault() inside onClick intercepts that toggle (handy for doing an asynchronous authorization check first).
How it works
- The play triangle is cut down its midline into two quadrilaterals, sharing the same four-point path structure as the two pause bars, which makes it possible to interpolate the SVG
dattribute point by point so the shape genuinely "morphs" rather than cross-fading. - The morph uses a spring transition, so it settles with a natural spring back.
- The progress ring normalizes
pathLengthto 1 and expresses the played ratio throughstrokeDashoffset, tweening smoothly as the value changes.
Accessibility
- Built on a native
<button>, so bothEnterandSpacetrigger it and focus has a clearfocus-visibleoutline. - The
aria-labelfollows the state: "Pause" while playing and "Play" while paused, describing precisely what pressing it will do. - The graphics are hidden from assistive technology with
aria-hidden, so no meaningless vector content is announced. - When the user has "reduce motion" enabled at the system level, the morph and the scale feedback snap between states, with no effect on functionality.
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it from a Server Component page as usual.
Is the animation a cross-fade?
No. The play triangle is split into two quadrilaterals that share the same four-point path structure as the two pause bars, so Motion interpolates the d attribute point by point with a spring and the shape morphs continuously.
Can I use a custom size?
size offers sm, md and lg (32/40/48px). For anything else, pass a class such as size-14 in className; the icon is drawn in a viewBox and scales with it.
Does it support disabled and other button attributes?
Yes. Native <button> attributes are passed through, apart from a few drag and animation events that conflict with Motion. type defaults to "button", so it will not submit a surrounding form by accident.
Does it respect reduced motion?
Yes. With "reduce motion" on, the morph and progress ring transitions run with zero duration and the press-scale feedback is disabled.
Theme Toggle
A light/dark theme toggle button: View Transitions expand a circle out from the click point, and the Sun/Moon icons rotate through the transition.
Hold to Confirm
A press-and-hold fill confirmation button: holding fills the progress from left to right with a slight tremor near completion, and only a full fill triggers it — preventing accidental taps on dangerous actions.