ScrollFree

Corner Morph Overlay

A layer hidden under the scene, cut out by a clip-path circle() whose centre never moves (90% / 88%): 21 px radius at rest, 522 px at the end of travel, 624 px of ghost scrolling inside a transparent 260 px zone, a single --p variable written by requestAnimationFrame — and the screen is fully covered by 86% of the way.

Scrollclip-pathTransition
Scrollez ici ↓
Hover or click the scene to interact

This effect is free — the Scroll category contains 70 effects total, including 12 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

Preview: as you scroll, the interface switches from the light theme to the dark theme, spreading from the theme button.
Scroll here ↓

① Dark-mode switch demo for a note-taking app

WhenShowcase page of a note-taking app: the light interface (sidebar, open note) is shown first; on scroll, the dark version of the same interface takes over the screen from the theme button in the bottom-right corner.
WhyThe circular expansion starts from a fixed point, and here that point is the theme button: the effect becomes the metaphor of an activation spreading out. The blinds of fx-0778 reveal evenly across the full width, with no origin tied to an interface element. Both layers share the same layout, so the disc edge never garbles any text: only the colours flip.
SettingsOrigin at 90% 88% kept, aligned with the centre of the theme button; initial radius lowered to 0% (at rest, nothing gives the dark layer away: the disc is born under the button); layer A in #f6f7fb, layer B in #14151f; track at 260% for a switch completed in two wheel screens.
Preview: the antagonist's dark red world invades the page from the bottom-right corner as you scroll.
Keep reading ↓

② Introducing a new character in an interactive novel

WhenInteractive fiction: a page of narrative on a parchment background, a choice offered to the reader; the antagonist's world (dark red, embers) invades the page from the bottom-right corner as the reader scrolls.
WhyThe origin of the disc is semantic: it is the corner the character enters through, and the reader watches their world gain ground over the text. The concentric rings of fx-0781 draw a symmetrical figure at the centre, with neither direction nor intrusion. The narrative and the antagonist's line occupy separate areas of the frame, so the disc edge never cuts through a sentence.
SettingsLayer B #3b0a0f → #7a1220 with an rgba(255,120,40,.3) glow on the origin; initial radius lowered to 3% (an ember); amplitude 150%; track at 400% for a slow invasion; hint replaced with “Keep reading”.
Preview: the Premium plan covers the Standard plan from the bottom-right corner as you scroll.
Scroll here ↓

③ Upgrading to the Premium plan on a pricing page

WhenPricing section: the Standard card is displayed; scrolling into the card, the Premium plan bursts from the bottom-right corner and progressively covers the Standard card — same template, higher price and benefits.
WhyExpanding from a corner toward the centre reads as one tier covering the previous one: a hierarchy, not a mere transition. Corner Morph Cards (fx-0780) pulls several cards together while softening their corners, with no notion of covering. Both cards use exactly the same grid (name, price, four lines, button) so each line is replaced in place.
SettingsOrigin moved onto the Premium badge (at 88% 90%); layer B in a #4c1d95 → #7c3aed gradient with a gold glow; amplitude 121% so that full coverage coincides with the end of travel (no dead scrolling); “Go Premium” button readable from p = 0.5.

How it works

The .cmo-scene stage (430 × 260 px, overflow: hidden, variable --p: 0) stacks three absolute layers. On top, .cmo-scroll (z-index: 3, overflow-y: auto) holds nothing but an empty .cmo-track block 340% tall: 884 px of content for a 260 px window, i.e. 624 px of travel. It is a ghost scroller: the layer is transparent, nothing moves in it, it only exists to produce a scrollTop. Below, .cmo-stage (z-index: 1, pointer-events: none, aria-hidden) carries the two full-frame layers .cmo-a (“Chapitre I”, night gradient #16162a → #101020) and .cmo-b (“Chapitre II”, violet #2b1440 → #7c3aed with a 25% gold glow placed at 88% / 88%), each in display: grid; place-content: center. Between them, the .cmo-hint “Scrollez ici ↓” (z-index: 2) fades with opacity: calc(1 − var(--p) × 3): measured 0.52 at p = 0.16, 0.04 at p = 0.32, 0 from 0.333.

The script is fifteen lines of ES5: getElementById('cmoScroll'), closest('.cmo-scene'), then a passive scroll listener that only books a frame through requestAnimationFrame — a raf flag prevents double booking (measured: 30 scroll events dispatched in the same frame → a single write). update() computes p = scrollTop / (scrollHeight − clientHeight), rounds it to four decimals and writes it with style.setProperty('--p') on the scene; it runs once at init (p = 0). Measured with the wheel in 100 px notches: p advances by 0.16 per notch, the travel is complete on the 7th (624 px), eight writes in total. The ratio is unitless, hence independent of the scene size: no resize listener is needed. The script targets a single id: a second copy of the HTML stays at p = 0 (measured: scrollTop 300, --p unchanged) and the document then carries two id="cmoScroll".

The whole rendering is one rule: .cmo-b { clip-path: circle(calc(6% + var(--p) × 140%) at 90% 88%) }. The top layer does not fade; the hidden layer is cut out by a disc whose centre is fixed. A percentage radius resolves against √(width² + height²) / √2, 357 px for this scene: 21 px at rest — a violet disc already visible in the corner before any scrolling — and 522 px at p = 1. Yet the farthest corner (top-left) is only 452 px from the centre, 126.5%: the screen is fully covered from p = 0.861, and the last 87 px of scrolling change nothing (measured: identical image between p = 0.96 and p = 1). The threshold depends only on the aspect ratio, not the size: 0.861 at 1280 × 800 and at 1920 × 1080 as well. Another consequence, this time of two layers centring different texts at the same spot: between p ≈ 0.25 and 0.5 the edge of the disc slices through the titles and “Chapitre I” blends into “Chapitre II” (capture at p = 0.32: “CHAPITȽE II”). In an integration, give both layers the same layout — the three examples below do — or keep their texts apart.

Accessibility

  • prefers-reduced-motion is absent from the code — measured under reduce: p and the disc behave exactly as without the preference. The reveal follows the finger or the wheel, it is not autonomous animation (WCAG 2.3.3 targets interaction-triggered motion), but a disc sweeping the whole surface is the kind of large-scale transition the preference asks to tone down. Two-line fallback: @media (prefers-reduced-motion: reduce) { .cmo-b { clip-path: none; opacity: var(--p) } } — the second layer cross-fades in, at the same scroll pace, without the sweep.
  • Keyboard verified: tabindex="0" makes the zone focusable (Tab reaches it), Arrow down scrolls 40 px per press (three presses → p = 0.19), End sends it to p = 1, and :focus-visible draws a 2 px violet ring inside the frame. But the region is empty for a screen reader: role="region" and aria-label="Zone de démonstration au défilement" announce a frame with no content, the track is an empty div and both layers are aria-hidden. If the revealed texts matter (an offer, a chapter), put them — or a visually hidden copy — inside the scrolling track; and translate the aria-label, which ships in French.
  • Contrast (titles 16.8 px, subtitles 11.5 px): layer A, rgb(207,207,232) on #16162a = 11.6:1 and the 75%-opacity subtitle 7.0:1; layer B, white on #7c3aed = 5.7:1, but the 75% subtitle drops to 3.9:1 on the lightest part of the gradient, below the 4.5:1 threshold for 11.5 px text: raise it to 90% or darken the violet. The hint “Scrollez ici ↓” is 6.2:1 but 9.6 px (0.6rem), too small for many readers: bring it up to 11-12 px. The resting disc, #7c3aed on the night background, is 3.3:1 — enough for a graphical element.
  • Touch and coexistence with the page — measured in mobile emulation: a 250 px gesture scrolls the zone (p = 0.40), so the effect works by finger. The zone captures the wheel until it reaches the end of its travel, then scrolling hands over to the page (measured: one more notch after p = 1 moves the page by 101 px): no trap, but 624 px of wheel absorbed for a 260 px scene. Keep the scene short or reduce height: 340%; do not add overscroll-behavior: contain, which would strand a user whose pointer rests on the frame.

Browser compatibility

Requires clip-path: circle() with calc() and a CSS variable, the inset shorthand (four rules depend on it), place-content, closest(), requestAnimationFrame and passive listeners. Zero dependencies, 563 bytes of ES5 JavaScript. Progress is read in JavaScript (scrollTop), not through animation-timeline: scroll(): the effect therefore works in Firefox, which does not implement native CSS scroll-driven animations — a real selection criterion in this category.

Chrome 87+✓ Full
Firefox 66+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS 14.5+✓ Full (finger scrolling)
Android Chrome 87+✓ Full

Without JavaScript, --p stays at 0 (the style attribute in the shipped HTML sets it): layer A shows with the 21 px disc in the corner and the “Scrollez ici ↓” hint, and scrolling reveals nothing. On a browser that ignores inset (Safari ≤ 14.0, Chrome ≤ 86, Firefox ≤ 65), the four absolute layers have no dimensions left: a 0 × 0 scroll zone, dead effect. Replace inset: 0px with top: 0; right: 0; bottom: 0; left: 0 to reach back to Chrome 55, Firefox 54 and Safari 13.1, where unprefixed circle() is supported. Without clip-path at all (Edge 12-18), layer B, last in the DOM, covers layer A from the start: the end state, with no transition.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="cmo-scene" style="--p: 0.0000;">
  <div class="cmo-scroll" id="cmoScroll" tabindex="0" role="region" aria-label="Zone de démonstration au défilement">
    <div class="cmo-track" style="height:340%">
    </div>
  </div>
  <div class="cmo-stage" aria-hidden="true">
    <div class="cmo-a">
      <h4 style="font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;">Chapitre I</h4>
      <p>Continuez à défiler…</p>
    </div>
    <div class="cmo-b">
      <h4 style="font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;">Chapitre II</h4>
      <p>…le coin a tout englouti.</p>
    </div>
  </div>
  <span class="cmo-hint">Scrollez ici ↓</span>
</div>
CSS
.cmo-scene  {
   position: relative;
   width: 100%;
   max-width: 430px;
   height: 260px;
   border-radius: 12px;
   overflow: hidden;
   border: 1px solid rgba(255, 255, 255, 0.08);
   background: rgb(13, 13, 22);
   --p: 0;
   }

.cmo-scroll  {
   position: absolute;
   inset: 0px;
   overflow-y: auto;
   z-index: 3;
   scrollbar-width: thin;
   }

.cmo-stage  {
   position: absolute;
   inset: 0px;
   z-index: 1;
   pointer-events: none;
   }

.cmo-hint  {
   position: absolute;
   right: 10px;
   bottom: 8px;
   z-index: 2;
   font-size: 0.6rem;
   letter-spacing: 0.12em;
   text-transform: uppercase;
   color: rgb(143, 143, 176);
   opacity: calc(1 - var(--p) * 3);
   pointer-events: none;
   }

.cmo-scroll:focus-visible  {
   outline: rgb(167, 139, 250) solid 2px;
   outline-offset: -2px;
   }

.cmo-track  {
   width: 100%;
   }

.cmo-a, .cmo-b  {
   position: absolute;
   inset: 0px;
   display: grid;
   place-content: center;
   text-align: center;
   gap: 4px;
   }

.cmo-a  {
   background: linear-gradient(160deg, rgb(22, 22, 42), rgb(16, 16, 32));
   color: rgb(207, 207, 232);
   }

.cmo-b  {
   background: radial-gradient(circle at 88% 88%, rgba(255, 209, 102, 0.25), transparent 50%), linear-gradient(200deg, rgb(43, 20, 64), rgb(124, 58, 237));
   color: rgb(255, 255, 255);
   clip-path: circle(calc(6% + var(--p) * 140%) at 90% 88%);
   }

.cmo-a h4, .cmo-b h4  {
   margin: 0px;
   font-size: 1.05rem;
   letter-spacing: 0.18em;
   text-transform: uppercase;
   }

.cmo-a p, .cmo-b p  {
   margin: 0px;
   font-size: 0.72rem;
   opacity: 0.75;
   }
JavaScript (fx-0779)
(function() {
  /* fx-0779 — Corner Morph Overlay : progression de scroll → variable CSS --p (rAF, passif) */
  var sc = document.getElementById('cmoScroll');
  if (!sc) return;
  var host = sc.closest('.cmo-scene');
  var raf = null;
  function update() {
    raf = null;
    var max = sc.scrollHeight - sc.clientHeight;
    var p = max > 0 ? sc.scrollTop / max : 0;
    host.style.setProperty('--p', p.toFixed(4));
  }
  sc.addEventListener('scroll', function() {
    if (!raf) raf = requestAnimationFrame(update);
  }, { passive: true });
  update();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Travel length (HTML — .cmo-track height: 340%) 340% (624 px of scrolling for a 260 px window) Sets the “speed”: p goes from 0 to 1 over (height − 100%) of the window. 200% = 260 px of travel, disc completed in three wheel notches; 500% = 1,040 px, slow reveal. It is also the number of wheel pixels absorbed before the page takes over again.
Resting radius (CSS — circle(calc(6% + …))) 6% (21 px) Disc already visible before any scrolling, a cue showing where the effect is born. 0% = nothing at rest; 12% = 43 px. As a percentage of √(w² + h²) / √2, it follows the scene size.
Amplitude (CSS — var(--p) * 140%) 140% (522 px at p = 1) Radius added over the full travel. The opposite corner sits at 126.5%: 121% makes full coverage coincide with the end of travel; 200% covers the screen from p = 0.6 and leaves 40% of the travel without change.
Origin (CSS — at 90% 88%) 90% 88% (bottom-right corner) Fixed centre of the disc. at 50% 50% = an iris opening at the centre (the corner is only 70.4% away, coverage from p = 0.46); at 0% 0% = from the top-left corner. To anchor it on a button, use the button centre's coordinates as percentages of the scene.
Layers (CSS — .cmo-a, .cmo-b background) night #16162a → #101020 / violet #2b1440 → #7c3aed + 25% gold glow Any content works: two versions of one interface, an image and its caption, two cards. The radial glow of .cmo-b sits at 88% / 88%, on the origin: move it along with it.
Hint (CSS — .cmo-hint opacity: calc(1 − var(--p) * 3)) × 3 (invisible from p = 0.333) * 10 = gone after the first notch; * 1 = fades over the whole travel. 9.6 px text: raise font-size to at least 0.7 rem.
Dimensions (CSS — .cmo-scene max-width: 430px; height: 260px) 430 × 260 px The p ratio holds at any size (percentages everywhere), and the coverage threshold depends only on the aspect ratio. Full screen, replace the ghost zone with page scrolling (see FAQ).
Multiple instances (JS — getElementById('cmoScroll')) one (id) For several scenes, replace with document.querySelectorAll('.cmo-scroll').forEach(function (sc) { … }) and drop the id, or duplicate the IIFE with one id per scene — which is what examples 2 and 3 do.
Click trigger (JS — to add) scroll only --p is just a variable: scene.style.setProperty('--p', 1) on a button click, with transition: clip-path .6s ease on .cmo-b, gives the animated theme switch from the button, with no scrolling.

FAQ

Why is the reveal finished before the end of the scroll?

Because the final radius exceeds the scene. At p = 1 the disc is 6% + 140% = 146% of the reference (√(w² + h²) / √2 = 357 px), i.e. 522 px, while the farthest point from the centre — the top-left corner — is 452 px away, 126.5%. Layer B therefore covers everything from p = (126.5 − 6) / 140 = 0.861: the last 87 px of travel (out of 624) change nothing in the image, measured identical between p = 0.96 and p = 1. The threshold depends only on the aspect ratio: 0.861 at 1280 × 800 and 1920 × 1080 too, 0.85 in 390 × 844 portrait. For coverage to coincide with the end of travel, replace 140% with 121%; to keep a resting time once revealed, leave it as is — it is a choice, not a bug.

Corner Morph Overlay, SVG Blinds Mask (fx-0778) or Corner Morph Cards (fx-0780): which one?

All three share the same engine — the fifteen-line script is identical but for the prefix, and so is the 340% track; only the CSS rule consuming --p differs. fx-0778 closes eight .sbm-slat slats with scaleY(clamp(0, 1 − (var(--p) × 9 − var(--k)), 1)), one after the other, across the full width: a reveal with no origin, its caption only appearing from p = 0.75. fx-0779 cuts out a single disc whose centre never moves: it is the only one of the three where the reveal has a place — a button, a corner — and a direction. fx-0780 shares the name but reveals nothing: it tightens the gap, straightens the rotation and rounds the border-radius of three cards. Pick fx-0779 when the origin means something (theme, character, plan), fx-0778 to unveil an image with no spatial bias, fx-0780 to assemble elements.

How do I apply it to the whole page rather than a 260 px frame?

Remove the ghost zone (.cmo-scroll and .cmo-track) and let the page scroll. Put .cmo-stage in position: sticky; top: 0; height: 100vh inside a 340vh-tall section, then compute p from the section: var r = section.getBoundingClientRect(); var p = Math.min(1, Math.max(0, −r.top / (r.height − innerHeight))), in a passive scroll listener on window with the same requestAnimationFrame guard, and write --p on the section. The circle() percentages then resolve against the viewport: coverage at p = 0.861 in 16:10 as in 16:9. The sticky hands over to the next section at the end of travel, which position: fixed would not. It is the JavaScript version of what animation-timeline: view() would write in pure CSS — with the advantage of working in Firefox.