javascripthtmlcssanimationscroll

JavaScript/CSS Smooth “Iris” Scroll Transition: Circle Grows but Won’t Shrink on Scroll-Up


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>


Solution

  • With just a slight change in the HTML markup, and smartly using CSS you don't need most of the JavaScript

    Here'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!


    CSS - only solution using animation-timeline (2025. limited availability)

    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

    Using CSS and JavaScript

    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>
      );
    }