Ripple Press
A soft circle grows from the exact point you press and fades out, so the button answers where it was touched.
Create a React button with a press ripple built in CSS. On pointerdown, read the pointer position relative to the button and append a <span> at that point (position absolute, 12px circle, translate -50% -50%, background currentColor at 25% opacity). Animate it with a CSS keyframe from scale(0) opacity 1 to scale(24) opacity 0 over 600ms using cubic-bezier(0.16, 1, 0.3, 1), then remove the span on animationend. The button needs overflow: hidden and position: relative. Under prefers-reduced-motion, replace the ripple with a 120ms background colour change. The button label reads "Save changes".
Source
'use client'
import s from './demo.module.css'
export default function RipplePress({ labels }: { labels: Record<string, string> }) {
const onDown = (e: React.PointerEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect()
const dot = document.createElement('span')
dot.className = s.ripple
dot.style.left = `${e.clientX - r.left}px`
dot.style.top = `${e.clientY - r.top}px`
dot.addEventListener('animationend', () => dot.remove())
e.currentTarget.appendChild(dot)
}
return (
<button type="button" className={s.button} onPointerDown={onDown}>
{labels.cta}
</button>
)
}
.button {
position: relative;
overflow: hidden;
height: 52px;
padding: 0 32px;
border: 0;
border-radius: var(--radius-sm);
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 500 var(--text-base) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
transition: background-color var(--dur-micro) var(--ease-out);
}
.button:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 3px;
}
.ripple {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: currentColor;
opacity: 0.25;
translate: -50% -50%;
pointer-events: none;
animation: ripple 600ms var(--ease-out) forwards;
}
@keyframes ripple {
from { transform: scale(0); opacity: 0.25; }
to { transform: scale(24); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ripple { display: none; }
.button:active { background: var(--stage-accent-hover); }
}