I have a one-page site with five fullscreen 'Problem' cards (#problems
) and a fixed 'Solution time' section (#solutions
). When the user scrolls past Problem 5, a circular mask should grow from the center (radius 0 → full viewport) revealing #solutions
. Scroll-down works perfectly.
The issue is that when the circle reaches full size and I scroll back up, the page instantly jumps to Problem 5 and the circle snaps closed - there's no smooth, proportional shrinking on scroll-up.
My expectation is:
(() => {
// Get the two sections: the problems you scroll past and the solutions you reveal
const problems = document.getElementById('problems');
const solutions = document.getElementById('solutions');
const body = document.body;
const html = document.documentElement;
/* -------- Robust way to read the current vertical scroll position -------- */
const getScrollY = () =>
window.pageYOffset ||
html.scrollTop ||
body.scrollTop ||
window.visualViewport?.offsetTop ||
0;
/* -------- Calculate the vertical boundaries where the clip animation starts/ends -------- */
let viewportHeight = window.innerHeight;
// The top position of the solutions section
let topOfSolutions = problems.offsetTop + problems.offsetHeight;
// When to start revealing solutions: one viewportHeight before the section
let startReveal = topOfSolutions - viewportHeight;
// When to be fully revealed
let endReveal = topOfSolutions;
let revealRatio = 0; // Will go from 0 (hidden) to 1 (fully revealed)
let isLocked = false; // Are we locking the page scroll to control the reveal?
/* Maximum radius for the circular clip: half the diagonal of the viewport */
const maxRadius = () => Math.hypot(window.innerWidth / 2, window.innerHeight / 2);
/* Apply the circular clip with given radius (in pixels) */
const setRadius = (r) => {
const clip = `circle(${r}px at 50% 50%)`;
solutions.style.clipPath = clip;
solutions.style.webkitClipPath = clip;
};
/* -------- Functions to lock/unlock the normal page scroll -------- */
const lockScroll = (yPos) => {
isLocked = true;
body.style.overflow = 'hidden';
window.scrollTo(0, yPos);
};
const unlockScroll = (yPos) => {
isLocked = false;
body.style.overflow = 'auto';
window.scrollTo({
top: yPos,
behavior: 'auto'
});
};
/* ---------- Main scroll handler: controls the clip during scrolling ---------- */
window.addEventListener('scroll', () => {
if (isLocked) return; // if we're locked, ignore normal scroll events
const currentY = getScrollY();
if (currentY < startReveal) {
// Above the start zone: keep circle closed
revealRatio = 0;
setRadius(0);
return;
}
if (currentY > endReveal) {
// Below the end zone: circle fully open
revealRatio = 1;
setRadius(maxRadius());
return;
}
// Inside the transition zone: compute how far we are in it
revealRatio = (currentY - startReveal) / (endReveal - startReveal);
setRadius(revealRatio * maxRadius());
// Lock the scroll so we can use wheel/touch to drive the reveal
// Decide which edge to snap to if the user reverses direction
const midpoint = (startReveal + endReveal) / 2;
lockScroll(currentY < midpoint ? startReveal : endReveal);
}, {
passive: true
});
/* ---------- Helper to advance the reveal by a delta, then release lock if done ---------- */
const advanceReveal = (deltaY) => {
// convert delta scroll into a change in ratio
revealRatio = Math.max(0, Math.min(1, revealRatio + deltaY / viewportHeight));
setRadius(revealRatio * maxRadius());
if (revealRatio === 1) unlockScroll(endReveal); // fully revealed → resume normal scroll down
if (revealRatio === 0) unlockScroll(startReveal); // fully hidden → resume normal scroll up
};
/* ---------- Mouse wheel while locked: drive the reveal ---------- */
window.addEventListener('wheel', (e) => {
if (!isLocked) return; // only intercept if we're in the locked state
e.preventDefault(); // prevent the page from scrolling
advanceReveal(e.deltaY);
}, {
passive: false
});
/* ---------- Touch drag while locked: similar to wheel ---------- */
window.addEventListener('touchmove', (e) => {
if (!isLocked) return;
e.preventDefault();
const touch = e.touches[0];
if (touch._prevY === undefined) {
touch._prevY = touch.clientY;
}
const dy = touch._prevY - touch.clientY;
touch._prevY = touch.clientY;
advanceReveal(dy);
}, {
passive: false
});
/* ---------- Recalculate dimensions on resize ---------- */
window.addEventListener('resize', () => {
viewportHeight = window.innerHeight;
topOfSolutions = problems.offsetTop + problems.offsetHeight;
startReveal = topOfSolutions - viewportHeight;
endReveal = topOfSolutions;
if (!isLocked) {
// update current clip if not locked
setRadius(revealRatio * maxRadius());
}
});
})();
html,
body {
height: 100%;
margin: 0;
font-family: sans-serif
}
/* Problems */
#problems {
scroll-snap-type: y mandatory;
position: relative
}
#problems h1 {
position: sticky;
top: 0;
background: #fff;
padding: 1rem;
text-align: center;
z-index: 1
}
.card {
height: 100vh;
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #fff
}
.card:nth-child(2) {
background: #90caf9
}
.card:nth-child(3) {
background: #a5d6a7
}
.card:nth-child(4) {
background: #ce93d8
}
.card:nth-child(5) {
background: #ffcc80
}
/* Solutions (masked) */
#solutions {
position: fixed;
inset: 0;
background: #000;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
clip-path: circle(0px at 50% 50%);
-webkit-clip-path: circle(0px at 50% 50%);
pointer-events: none;
z-index: 50;
}
<section id="problems">
<h1>Do you know these problems?</h1>
<article class="card">Problem 1</article>
<article class="card">Problem 2</article>
<article class="card">Problem 3</article>
<article class="card">Problem 4</article>
<article class="card">Problem 5</article>
</section>
<!-- fixed layer revealed by the circle -->
<section id="solutions">
<h2>Solution</h2>
</section>
With just a slight change in the HTML markup, and smartly using CSS you don't need most of the JavaScript
#solutions
inside the #problems
parentsticky
to the last problem card, and to the #solutions
elementHere's a POC example (without JS):
* { margin: 0; box-sizing: border-box; }
body {
font: 1rem/1.4 system-ui, sans-serif;
}
#problems h1 {
position: sticky;
top: 0;
background: #fff;
padding: 1rem;
text-align: center;
z-index: 1;
}
.card {
height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #fff;
background: var(--bg);
&.stick {
position: sticky;
top: 0;
}
}
#solutions {
position: sticky;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #000;
pointer-events: none;
clip-path: circle(50% at 50% 50%); /* THIS EXAMPLE ONLY */
opacity: 0.5; /* THIS EXAMPLE ONLY */
}
<section id="problems" style="--totCards:5;">
<h1>Do you know these problems?</h1>
<article class="card" style="--bg:#9cf;">Problem 1</article>
<article class="card" style="--bg:#ada;">Problem 2</article>
<article class="card" style="--bg:#c9d;">Problem 3</article>
<article class="card" style="--bg:#fc8;">Problem 4</article>
<article class="card stick" style="--bg:#c8f;">Problem 5</article>
<section class="card" id="solutions">
<h2>Solution</h2>
</section>
</section>
the above does not uses JavaScript and serves just as a demo to showcase that you can stop ad the 5th card and still have available Y scrollbars (of the exact #solutions height!) that can be used (using JS) to "clip-reveal" your #solutions element circle.
That's. it.
Remove from the above proof-of-concept the opacity: 0.5;
and reset to clip-path: circle(0% at 50% 50%);
. Then in JavaScript without any extra fancy code, just listen for the "scroll"
event. If you know the number of cards you can easily calculate when it's time to reveal your #solutions
(given all .card
are 100dvh?) radius = scrollYPercent / totCards * (totCards - 1)
.
PS: I really don't want to spoil the JS fun should be trivial, but if you encounter implementation issues, let me know!
Soon you won't need any JS at all, thanks to the amazing animation-timeline
Here's an ahead-of-time example (works currently in Chromium-based browsers) to spark some joy
* {
margin: 0;
box-sizing: border-box;
}
@property --scrollPct {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
body {
font: 1rem/1.4 system-ui, sans-serif;
}
#problems h1 {
position: sticky;
top: 0;
background: #fff;
padding: 1rem;
text-align: center;
z-index: 1;
}
.card {
height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #fff;
background: var(--bg);
&.stick {
position: sticky;
top: 0;
}
}
/* Solutions (masked) */
#solutions {
position: sticky;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #000;
pointer-events: none;
clip-path: circle(calc(var(--scrollPct) * 1%) at 50% 50%);
animation: animScroll linear;
animation-timeline: scroll(block root);
animation-range: calc(100 / var(--totCards) * (var(--totCards) - 1) * 1%) 100%;
animation-duration: 1ms;
/* for firefox */
}
@keyframes animScroll {
0% {
--scrollPct: 0;
}
to {
--scrollPct: 100;
}
}
<section id="problems" style="--totCards:5;">
<h1>Do you know these problems?</h1>
<article class="card" style="--bg:#9cf;">Problem 1</article>
<article class="card" style="--bg:#ada;">Problem 2</article>
<article class="card" style="--bg:#c9d;">Problem 3</article>
<article class="card" style="--bg:#fc8;">Problem 4</article>
<article class="card stick" style="--bg:#c8f;">Problem 5</article>
<section class="card" id="solutions">
<h2>Solution</h2>
</section>
</section>
PS: The above would be even more precise if the scrollbars were assigned to a flex: 1
container/parent (below the heading H1
) instead of on the entire document, and by then using scroll(block nearest)
To better capture the essence of calculating the scroll percentage see this similar answer
* { margin: 0; box-sizing: border-box; }
@property --scrollPct {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
body {
font: 1rem/1.4 system-ui, sans-serif;
}
#problems {
height: 100dvh;
display: flex;
flex-direction: column;
h1 {
text-align: center;
}
.cards {
flex: 1;
overflow: auto;
font-size: 2rem;
}
.card {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--bg);
&.stick {
position: sticky;
top: 0;
}
&.solutions {
position: sticky;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #000 !important;
pointer-events: none;
clip-path: circle(calc(var(--scrollPct) * 1%) at 50% 50%);
animation: animScroll linear;
animation-timeline: scroll(block nearest);
animation-range: calc(100 / var(--totCards) * (var(--totCards) - 0.9) * 1%) 100%;
animation-duration: 1ms; /* for firefox */
}
}
}
@keyframes animScroll {
0% { --scrollPct: 0; }
to { --scrollPct: 100; }
}
<section id="problems" style="--totCards:5;">
<h1>Do you know these problems?</h1>
<div class="cards">
<article class="card" style="--bg:#9cf;">Problem 1</article>
<article class="card" style="--bg:#ada;">Problem 2</article>
<article class="card" style="--bg:#c9d;">Problem 3</article>
<article class="card" style="--bg:#fc8;">Problem 4</article>
<article class="card stick" style="--bg:#c8f;">Problem 5</article>
<article class="card solutions">
<h2>Solutions</h2>
</article>
</div>
</section>
SPOILER ALERT
const elCards = document.querySelector("#problems .cards");
const elSolutions = document.querySelector("#problems .solutions");
const totCards = 5; // actually - the (last) sticky one
const convertScrollRange = (y, start) => Math.max(0, ((y - start) / (100 - start)) * 100);
const calculateReveal = () => {
const sTop = elCards.scrollTop;
const sHeight = elCards.scrollHeight;
const height = elCards.clientHeight;
const sMax = sHeight - height;
const sPct = sTop / sMax * 100;
const radius = convertScrollRange(sPct, 100 / totCards * (totCards - 1))
elSolutions.style.setProperty("--radius", radius);
};
elCards.addEventListener("scroll", calculateReveal);
addEventListener("resize", calculateReveal);
* { margin: 0; box-sizing: border-box; }
body {
font: 1rem/1.4 system-ui, sans-serif;
}
#problems {
height: 100dvh;
display: flex;
flex-direction: column;
h1 {
text-align: center;
}
.cards {
flex: 1;
overflow: auto;
font-size: 2rem;
}
.card {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--bg);
&.stick {
position: sticky;
top: 0;
}
&.solutions {
--radius: 0;
position: sticky;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #000 !important;
pointer-events: none;
clip-path: circle(calc(var(--radius) * 1%) at 50%);
}
}
}
<section id="problems">
<h1>Do you know these problems?</h1>
<div class="cards">
<article class="card" style="--bg:#9cf;">Problem 1</article>
<article class="card" style="--bg:#ada;">Problem 2</article>
<article class="card" style="--bg:#c9d;">Problem 3</article>
<article class="card" style="--bg:#fc8;">Problem 4</article>
<article class="card stick" style="--bg:#c8f;">Problem 5</article>
<article class="card solutions">
<h2>Solutions</h2>
</article>
</div>
</section>
A TSX component would look someting like i.e:
import { useRef, useEffect, useState } from 'react';
export default function ScrollRevealProblems() {
const cardsRef = useRef(null);
const solutionsRef = useRef(null);
const [radius, setRadius] = useState(0);
const totCards = 5; // actually - the (last) sticky one
const convertScrollRange = (y, start) => Math.max(0, ((y - start) / (100 - start)) * 100);
const calculateReveal = () => {
if (!cardsRef.current) return;
const elCards = cardsRef.current;
const sTop = elCards.scrollTop;
const sHeight = elCards.scrollHeight;
const height = elCards.clientHeight;
const sMax = sHeight - height;
const sPct = sTop / sMax * 100;
const newRadius = convertScrollRange(sPct, 100 / totCards * (totCards - 1));
setRadius(newRadius);
};
useEffect(() => {
const elCards = cardsRef.current;
if (!elCards) return;
// Add event listeners
elCards.addEventListener("scroll", calculateReveal);
window.addEventListener("resize", calculateReveal);
// Initial calculation
calculateReveal();
// Cleanup
return () => {
elCards.removeEventListener("scroll", calculateReveal);
window.removeEventListener("resize", calculateReveal);
};
}, []);
// Update CSS variable when radius changes
useEffect(() => {
if (solutionsRef.current) {
solutionsRef.current.style.setProperty("--radius", radius);
}
}, [radius]);
return (
<section className="h-screen flex flex-col">
<h1 className="text-center text-2xl font-bold py-4">Do you know these problems?</h1>
<div
ref={cardsRef}
className="flex-1 overflow-auto text-3xl"
>
<article
className="h-full flex items-center justify-center text-white"
style={{ background: '#9cf' }}
>
Problem 1
</article>
<article
className="h-full flex items-center justify-center text-white"
style={{ background: '#ada' }}
>
Problem 2
</article>
<article
className="h-full flex items-center justify-center text-white"
style={{ background: '#c9d' }}
>
Problem 3
</article>
<article
className="h-full flex items-center justify-center text-white"
style={{ background: '#fc8' }}
>
Problem 4
</article>
<article
className="h-full flex items-center justify-center text-white sticky top-0"
style={{ background: '#c8f' }}
>
Problem 5
</article>
<article
ref={solutionsRef}
className="h-full sticky bottom-0 text-white flex items-center justify-center text-3xl pointer-events-none"
style={{
background: '#000',
clipPath: `circle(${radius}% at 50%)`,
'--radius': radius
}}
>
<h2>Solutions</h2>
</article>
</div>
</section>
);
}