Icon morph blueprint

Play → Pause

A media affordance changes state in place so playback feels immediate and reversible.

Playground

Playhead 0ms / 180ms

Build it

Choose a target and adapt the source to your interface.

82 lines
index.html
<button
  class="morph-control"
  type="button"
  aria-pressed="false"
  aria-label="Play → Pause"
>
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <path
      d="M7 4 L12 7 L12 17 L7 20 Z"
      fill="currentColor"
      stroke="none"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
    <path
      d="M12 7 L20 12 L20 12 L12 17 Z"
      fill="currentColor"
      stroke="none"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
</button>

<script>
  const control = document.querySelector('.morph-control');
  const shapes = [
    { from: 'M7 4 L12 7 L12 17 L7 20 Z', to: 'M6 4 L10 4 L10 20 L6 20 Z' },
    { from: 'M12 7 L20 12 L20 12 L12 17 Z', to: 'M14 4 L18 4 L18 20 L14 20 Z' },
  ];
  const paths = [...control.querySelectorAll('path')];
  let active = false;
  control.addEventListener('click', () => {
    active = !active;
    control.setAttribute('aria-pressed', String(active));
    paths.forEach((path, index) => {
      const start = active ? shapes[index].from : shapes[index].to;
      const end = active ? shapes[index].to : shapes[index].from;
      path.getAnimations().forEach((animation) => animation.cancel());
      path.setAttribute('d', end);
      if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      path.animate(
        [{ d: 'path("' + start + '")' }, { d: 'path("' + end + '")' }],
        {
          duration: 180,
          easing: 'cubic-bezier(0.2,0.8,0.2,1)',
        },
      );
    });
  });
</script>

<style>
  .morph-control {
    display: inline-grid;
    place-items: center;
    width: 44px;
    height: 44px;
    padding: 10px;
    border: 0;
    border-radius: 6px;
    background: transparent;
    color: #20252d;
    cursor: pointer;
  }
  .morph-control svg {
    width: 24px;
    height: 24px;
    overflow: visible;
  }
  .morph-control:focus-visible {
    outline: 2px solid #175bc5;
    outline-offset: 4px;
  }
  @media (prefers-reduced-motion: reduce) {
    .morph-control {
      transition: none;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

180ms
01

Rest: one centred play triangle.

02

0–80ms: triangle narrows from its rear edge.

03

80–160ms: the new gap opens between two equal bars.

04

160–180ms: hold.

Motion intent

A media affordance changes state in place so playback feels immediate and reversible.

This is direct manipulation feedback, so it completes within the immediate-response window.

The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.

Motion specification

Trigger
Playback toggles.
Timing
180ms
Easing
cubic-bezier(.2, .8, .2, 1)

Animated properties

transformopacity

Implementation notes

  • Keep play and pause aligned to the same visual anchor.
  • Drive the transition from semantic state, not a timer, when the trigger is interactive.
  • Use transform, opacity and avoid layout work inside the animation loop.

Reduced motion

Swap to the final state instantly, preserving colour, label, and accessible state.

  • Use a minimum 44 by 44 pixel interactive target.
  • Expose the play and pause states with aria-pressed, aria-expanded, or an updated accessible label.
  • Keep visible keyboard focus independent from the animation.

Avoid

  • Do not add bounce unless a real object pickup or spring settle is part of the interaction.
  • Do not hide the final state from assistive technology.
  • Do not make the user wait for animation before the underlying state updates.

Related blueprints

Browse all blueprints