Interaction blueprint

Pointer-origin ripple

A clipped ripple makes the tap location visible while keeping the action surface stable.

Playground

Playhead 0ms / 420ms

Build it

Choose a target and adapt the source to your interface.

74 lines
index.html
<button class="ripple-button" type="button">Continue</button>

<script>
  const button = document.querySelector('.ripple-button');
  function ripple(x, y) {
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const size = Math.hypot(button.clientWidth, button.clientHeight) * 2;
    const circle = document.createElement('span');
    circle.className = 'ripple';
    circle.setAttribute('aria-hidden', 'true');
    Object.assign(circle.style, {
      width: size + 'px',
      height: size + 'px',
      left: x - size / 2 + 'px',
      top: y - size / 2 + 'px',
    });
    circle.addEventListener('animationend', () => circle.remove(), {
      once: true,
    });
    button.append(circle);
  }
  button.addEventListener('pointerdown', (event) => {
    const rect = button.getBoundingClientRect();
    ripple(event.clientX - rect.left, event.clientY - rect.top);
  });
  button.addEventListener('click', (event) => {
    if (event.detail === 0)
      ripple(button.clientWidth / 2, button.clientHeight / 2);
  });
</script>

<style>
  .ripple-button {
    position: relative;
    overflow: hidden;
    min-height: 44px;
    padding: 12px 24px;
    border: 0;
    border-radius: 6px;
    background: #175bc5;
    color: white;
    font: 14px system-ui;
    cursor: pointer;
  }
  .ripple-button:focus-visible {
    outline: 2px solid #175bc5;
    outline-offset: 4px;
  }
  .ripple {
    position: absolute;
    border-radius: 50%;
    background: rgb(255 255 255 / 0.35);
    pointer-events: none;
    animation: ripple-out 420ms cubic-bezier(0, 0, 0.2, 1) forwards;
  }
  @keyframes ripple-out {
    from {
      transform: scale(0);
      opacity: 1;
    }
    to {
      transform: scale(1);
      opacity: 0;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .ripple {
      display: none;
    }
    .ripple-button:active {
      background: #124a9f;
    }
  }
</style>

Animation breakdown

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

420ms
01

Rest: quiet surface.

02

0–40ms: ripple begins at pointer coordinates.

03

40–280ms: circle expands past the bounds.

04

280–420ms: opacity dissolves.

Motion intent

A clipped ripple makes the tap location visible while keeping the action surface stable.

This duration leaves enough time to read the state change while keeping the interface responsive.

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

Motion specification

Trigger
A surface receives a pointer tap.
Timing
420ms
Easing
cubic-bezier(0, 0, .2, 1)

Animated properties

scaleopacityprogress

Implementation notes

  • Keep rest and acknowledged aligned to the same visual anchor.
  • Drive the transition from semantic state, not a timer, when the trigger is interactive.
  • Use scale, opacity, progress 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 rest and acknowledged 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