Buttons & Toggles

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

New project or installation trouble? Start here →

Play/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.

0.4
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
playingboolean—Controlled playing state; when provided, it is driven from outside
defaultPlayingbooleanfalseInitial 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)
progressnumber—Optional progress from 0–1; when provided, the outer progress ring is shown
playLabelstring"Play"aria-label while paused (pressing it will play)
pauseLabelstring"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 d attribute 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 pathLength to 1 and expresses the played ratio through strokeDashoffset, tweening smoothly as the value changes.

Accessibility

  • Built on a native <button>, so both Enter and Space trigger it and focus has a clear focus-visible outline.
  • The aria-label follows 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.