Accordion expandWeb · App
Target
Implement the "Ripple press" effect in vanilla HTML/CSS/JS. Context: A circular ink ripple expands from the touch point and fades, the Material press feedback. Trigger: the element is pressed (pointerdown → pointerup). Timeline: time-based (duration + easing). Motion tokens (normal): - scale (ripple): 0 → 1 (covers the farthest corner) - opacity (ripple): 0.32 → 0 - duration: 550ms - easing: cubic-bezier(0.4, 0, 0.2, 1) Motion tokens (prefers-reduced-motion): - opacity (whole surface overlay): 0 → 0.12 then back - duration: 150ms - easing: ease-out - note: no expanding circle, a flat flash instead Technique: Web Animations API is acceptable. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - ripple radius is computed so the circle reaches the farthest corner of the surface - container has overflow hidden and position relative; ripple is pointer-events none
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-ripple-press" data-fx-root> <button type="button" class="fx-surface">Tap me</button> </div>
demo.css
.fx-ripple-press { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-ripple-press .fx-surface { position: relative; overflow: hidden; width: 60%; max-width: 280px; height: 56px; border: 0; border-radius: 12px; background: #fff; color: var(--fx-ink, #18181b); box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px var(--fx-muted, #e4e4e7); font: 500 15px/1 Inter, system-ui, sans-serif; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.fx-ripple-press .fx-ink { position: absolute; border-radius: 50%; background: var(--fx-accent, #4f46e5); pointer-events: none; transform: scale(0); opacity: .32; }
demo.js
export default function mount(root) {
const surface = root.querySelector('.fx-surface');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
function ripple(x, y) {
const r = surface.getBoundingClientRect();
const d = 2 * Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y)); // reach the farthest corner
const ink = document.createElement('span'); ink.className = 'fx-ink';
if (reduced) { Object.assign(ink.style, { inset: '0', borderRadius: '0', transform: 'none' }); ink.animate([{ opacity: 0 }, { opacity: .12 }, { opacity: 0 }], { duration: 300, easing: 'ease-out' }).onfinish = () => ink.remove(); }
else { Object.assign(ink.style, { width: d + 'px', height: d + 'px', left: x - d / 2 + 'px', top: y - d / 2 + 'px' }); ink.animate([{ transform: 'scale(0)', opacity: .32 }, { transform: 'scale(1)', opacity: 0 }], { duration: 550, easing: 'cubic-bezier(.4, 0, .2, 1)' }).onfinish = () => ink.remove(); }
surface.appendChild(ink);
}
const onDown = (e) => { const r = surface.getBoundingClientRect(); ripple(e.clientX - r.left, e.clientY - r.top); };
surface.addEventListener('pointerdown', onDown);
return {
play() { const r = surface.getBoundingClientRect(); ripple(r.width * 0.3, r.height * 0.6); },
stop() { surface.querySelectorAll('.fx-ink').forEach((n) => n.remove()); },
};
}
Inspired by m3.material.io — Material state layers / ripple. The preview is an original re-creation; the effect name belongs to its author.
Web Animations API because each ripple is a throwaway element with per-press geometry; CSS keyframes would need custom properties for every press. In Flutter this is `InkWell`/`InkResponse`, so the Flutter prompt only needs the colour and timing.
Added 2026-11-09 · Report an issue