Interaction blueprint
Pointer-origin ripple
A clipped ripple makes the tap location visible while keeping the action surface stable.
Build it
Choose a target and adapt the source to your interface.
<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.
Rest: quiet surface.
0–40ms: ripple begins at pointer coordinates.
40–280ms: circle expands past the bounds.
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
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.