Circle reveal from click
The next view expands as a circle from exactly where you clicked, using an animated clip-path.
CSSJavaScriptAdvanced
<div class="view base"><div><h1>Click anywhere</h1><p>The circle starts at your pointer</p></div></div>
<div class="view next" id="next"><div><h1>Revealed</h1><p>Click again to close it</p></div></div>
body{overflow:hidden;display:block;cursor:pointer}
.view{position:absolute;inset:0;display:grid;place-items:center;text-align:center;padding:20px}
.view h1{margin:0 0 10px;font-size:clamp(2rem,8vw,3.6rem);letter-spacing:-.03em}
.view p{margin:0;opacity:.7}
.base{background:#140807;color:#fff}
.next{background:#f08a5d;color:#270100;clip-path:circle(0 at var(--x,50%) var(--y,50%))}
.next.run{animation:grow .8s cubic-bezier(.65,0,.35,1) forwards}
.next.back{animation:shrink .8s cubic-bezier(.65,0,.35,1) forwards}
@keyframes grow{to{clip-path:circle(var(--r) at var(--x) var(--y))}}
@keyframes shrink{from{clip-path:circle(var(--r) at var(--x) var(--y))}to{clip-path:circle(0 at var(--x) var(--y))}}
const next = document.getElementById('next');
let open = false;
document.addEventListener('click', e => {
const x = e.clientX, y = e.clientY;
// radius to the farthest corner, so the circle always covers everything
const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
next.style.setProperty('--x', x + 'px');
next.style.setProperty('--y', y + 'px');
next.style.setProperty('--r', r + 'px');
next.classList.remove('run', 'back');
void next.offsetWidth; // restart the animation
next.classList.add(open ? 'back' : 'run');
open = !open;
});
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Circle reveal</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{display:grid;place-items:center;background:#140807;color:#fffffc;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
body{overflow:hidden;display:block;place-items:normal;cursor:pointer}
.view{position:absolute;inset:0;display:grid;place-items:center;text-align:center;padding:20px}
.view h1{margin:0 0 10px;font-size:clamp(2rem,8vw,3.6rem);letter-spacing:-.03em}
.view p{margin:0;opacity:.7}
.base{background:#140807;color:#fff}
.next{background:#f08a5d;color:#270100;clip-path:circle(0 at var(--x,50%) var(--y,50%))}
.next.run{animation:grow .8s cubic-bezier(.65,0,.35,1) forwards}
.next.back{animation:shrink .8s cubic-bezier(.65,0,.35,1) forwards}
@keyframes grow{to{clip-path:circle(var(--r) at var(--x) var(--y))}}
@keyframes shrink{from{clip-path:circle(var(--r) at var(--x) var(--y))}to{clip-path:circle(0 at var(--x) var(--y))}}
</style></head>
<body>
<div class="view base"><div><h1>Click anywhere</h1><p>The circle starts at your pointer</p></div></div>
<div class="view next" id="next"><div><h1>Revealed</h1><p>Click again to close it</p></div></div>
<script>
const next = document.getElementById('next');
let open = false;
document.addEventListener('click', e => {
const x = e.clientX, y = e.clientY;
// radius to the farthest corner, so the circle always covers everything
const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
next.style.setProperty('--x', x + 'px');
next.style.setProperty('--y', y + 'px');
next.style.setProperty('--r', r + 'px');
next.classList.remove('run', 'back');
void next.offsetWidth; // restart the animation
next.classList.add(open ? 'back' : 'run');
open = !open;
});
</script>
</body></html>
Good for
Theme toggles, opening a detail view or any transition that should feel connected to the tap.
Watch out for
Compute the radius to the farthest corner so the circle always covers the whole screen.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.