ScrollFree

Corner Morph Cards

Three 86 × 116 px cards, tilted −9°, 5° and 11°, with mismatched corners (50 / 14 / 46 / 18 px), that straighten up, round off evenly and move closer — the gap goes from 34 to 12 px — driven by a single --p variable read over 624 px of phantom scrolling. Four properties animated through calc(), 561 bytes of JavaScript, and a convergence measured at 20 px per card.

Scrollborder-radiusCards
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, four scattered profile cards straighten up and gather into one group. Team: Léa Marchand, art direction; Sami Benali, development; Inès Roux, strategy; Tom Keller, production.
Scroll here ↓

① A project team coming together on scroll

When“Our team” section of a digital agency: four profiles (art direction, development, strategy, production) shown far apart, each tilted its own way; as you scroll, they straighten up and gather into a single group, and the caption switches from “Four freelancers” to “One single team”.
WhySeveral distinct elements moving closer is the visual metaphor for cohesion — this is the only morph in the batch that handles a group of converging objects: Corner Morph Overlay (fx-0779) and Concentric Rings Morph (fx-0781) act on a single layer or a single figure. Four cards straightening at the same time say “individuals becoming a team” without a word.
SettingsFour 84 × 108 px cards (avatar, name, role); gap calc(52px − var(--p) × 44px) (52 → 8 px: 66 px of travel per outer card, against 20 in the sold code); tilts −12deg / 6deg / −5deg / 10deg; captions in opacity: calc(1 − var(--p) × 2) and calc(var(--p) × 2 − 1); names and roles repeated in the track for screen readers.
Preview: as you scroll, four scattered ingredient tiles — olive oil, garlic, tomatoes, basil — line up and compose the dish: spaghetti al pomodoro.
Scroll here ↓

② Visual assembly of a signature recipe's ingredients

WhenPage of a fine-dining recipe: the four ingredients (olive oil, garlic, tomatoes, basil) are first shown scattered, each on its own tilted tile; as you scroll, they line up side by side and form the dish's composition, whose name appears.
WhyThe convergence conveys culinary assembly: the elements already visible are what moves to compose the dish. The slats of SVG Blinds Mask (fx-0778) are a reveal mask — they uncover an image, they move nothing. Here nothing is hidden: the ingredients exist from rest, and the scroll gesture brings them together.
SettingsParchment background #f7efe2, four 84 × 104 px tiles in light tints (gold, ivory, tomato, basil) with dark text to keep 4.5:1; gap calc(56px − var(--p) × 48px); tilts −8deg / 6deg / −10deg / 7deg; dish name in opacity: calc(var(--p) × 2 − 1), visible from p = 0.5.
Preview: as you scroll, four separate sensor gauges — temperature 62 °C, pressure 4.2 bar, flow 18 m³/h, vibration 2.1 mm/s — tighten into one consolidated supervision block.
Scroll here ↓

③ Aggregating IoT sensor feeds into a dashboard

WhenIndustrial monitoring: four gauges from remote sensors (temperature, pressure, flow, vibration) appear separated, each in its own frame; as you scroll, they tighten and align into a single supervision block, corners almost square and a 2 px gap.
WhyThe convergence stands for disparate data merging into one consolidated view — Scroll Progress Beam (fx-0793) shows where you are in the page, it does not rearrange data elements among themselves. Four frames welding into one panel say “consolidated data” better than a heading.
SettingsFour 88 × 108 px gauges; gap calc(52px − var(--p) × 50px) (2 px at the end); corners rewritten as calc(36px − p × 32px) calc(12px − p × 8px) calc(32px − p × 28px) calc(16px − p × 12px) to end at 4 px; tilts −7deg / 4deg / −4deg / 8deg; header “4 remote feeds” → “1 consolidated view”.

How it works

The .cmc-scene (430 × 260 px, overflow: hidden, rgb(13,13,22) background, --p: 0 variable) stacks two absolute layers. On top, .cmc-scroll (z-index: 3, overflow-y: auto, tabindex="0") holds nothing but an empty .cmc-track block 340% tall: 884 px of content for a 260 px window, i.e. 624 px of travel — a phantom scroll, transparent, that exists only to produce a scrollTop. Underneath, .cmc-stage (z-index: 1, pointer-events: none, aria-hidden) carries a centered flex row .cmc-row and its three 86 × 116 px .cmc-cards: each gets its tilt --r (−9°, 5°, 11°) and its gradient --g inline (green #22ffaa → #0e7490, violet #7c3aed → #db2777, gold #ffd166 → #f4a300), a 17.6 px bold number at 90% white and a 0 10px 24px rgba(0,0,0,.45) shadow. Between the two, the .cmc-hint “Scrollez ici ↓” (9.6 px, z-index: 2) fades with opacity: calc(1 − var(--p) × 3): measured 0.52 at p = 0.16, 0.04 at 0.32, 0 from 0.333.

The script (561 bytes, ES5) is the engine shared by the morphs of this category, identical to Corner Morph Overlay's (fx-0779) but for the prefix: getElementById('cmcScroll'), closest('.cmc-scene'), then a passive scroll listener that only books a frame through requestAnimationFrame — the raf flag forbids double booking (measured: 30 scroll events dispatched within 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; an initial call sets p = 0. Measured with the wheel in 100 px notches: p advances by 0.1603 per notch, the travel is complete at the 7th, eight writes in total; once at the end, the next notch scrolls the page (101 px measured) — no trap. The ratio is unitless, hence size-independent: 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="cmcScroll".

The whole rendering lives in two rules that read --p inside calc(). On the row: gap: calc(34px − var(--p) × 22px), 34 px at rest, 12 px at the end. On each card: border-radius: calc(50px − p × 40px) calc(14px + p × 0px) calc(46px − p × 36px) calc(18px − p × 8px) — four mismatched corners (50 / 14 / 46 / 18) converging to 10 / 14 / 10 / 10, the second one being multiplied by 0px and therefore frozen — and transform: rotate(calc(var(--r) × (1 − p))) translateY(calc((1 − p) × 12px)): each card's own tilt cancels out and a 12 px lift settles down; since the translation follows the rotation, it acts along the tilted card's axis (measured: 1.9 px of horizontal drift for −9°). Measured in Chromium: the card centers go from 117 and 119 px apart to 98 px (86 + 12), i.e. 19 to 21 px of travel for each outer card over 624 px of scrolling, and the row tightens from 341 to 282 px wide. The feeling of assembly therefore comes as much from the straightening and the rounding as from the convergence, which stays modest. No transition: the rendering follows scrollTop to the pixel, and the scrolling itself (smoothed wheel, touch inertia) provides the fluidity.

Accessibility

  • prefers-reduced-motion is absent from the code — measured under reduce: at 300 px of scrolling, p = 0.48, gap 23.4 px and rotation −4.7°, exactly as without the preference. The motion follows the wheel or the finger (WCAG 2.3.3 targets interaction-triggered animation), but three objects that pivot, deform and move at once are the kind of motion the preference asks to tone down. One-rule fallback: @media (prefers-reduced-motion: reduce) { .cmc-card { transform: none; border-radius: 10px 14px 10px 10px } .cmc-row { gap: 12px } } — the assembled state from the start, with no travel.
  • Keyboard verified: tabindex="0" makes the zone focusable (Tab reaches it), Arrow Down scrolls 40 px per press (three presses → p = 0.19), End goes to p = 1, and :focus-visible draws a 2 px violet outline inside the frame. But the region is empty for a screen reader: role="region" and aria-label="Zone de démonstration au défilement" — shipped in French, to be translated — announce a frame whose track is an empty div, and the cards sit in an aria-hidden block. If the cards carry meaning (people, ingredients, sensors), write their content in the track or right below the scene.
  • Measured contrast (17.6 px bold numbers, 90% white, pixels read under the text at p = 1): card 02 on violet 4.6 to 4.8:1, compliant; card 01 on green 2.0 to 2.5:1 at the center and 1.35:1 in the light corner; card 03 on gold 1.6 to 1.7:1. No violation as long as the cards stay decorative (aria-hidden), but as soon as they carry a label: on gold, #3b2a00 gives 6.7 to 9.6:1 across the whole card; on green, no single color passes from one end of the gradient to the other (#0f172a: 7.3:1 at the center, 3.3:1 in the dark corner) — tighten the gradient or put the text on a dark band. The “Scrollez ici ↓” hint is 6.2:1 but 9.6 px (0.6rem): raise it to 11–12 px.
  • Touch and coexistence with the page — measured in mobile emulation (390 px): a 250 px gesture scrolls the zone (p = 0.40), the effect works by finger. The zone absorbs the wheel until the end of travel (624 px for a 260 px scene) then hands over to the page; do not add overscroll-behavior: contain, which would trap a pointer resting on the frame. Nothing signals that scrolling is possible except the 9.6 px hint and, on Windows, the scrollbar-width: thin bar on the right edge — on macOS it only appears while scrolling. Keep the scene short or reduce height: 340%.

Browser compatibility

Requires the inset shorthand (three rules depend on it), flexbox gap, place-items, calc() expressions multiplying a CSS variable by an angle (rotate) and by lengths (border-radius, gap), closest(), requestAnimationFrame and passive listeners. Zero dependencies, 561 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 criterion that matters 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 of the sold HTML sets it): three tilted cards with mismatched corners, 34 px apart, the hint visible, and scrolling changes nothing — a frozen but presentable state. On a browser that ignores inset (Chrome ≤ 86, Firefox ≤ 65, Safari ≤ 14.0), the three absolute layers lose their dimensions: a 0 × 0 scroll zone, dead effect. Replacing inset: 0px with top: 0; right: 0; bottom: 0; left: 0 only gains Chrome 84–86 and Firefox 63–65, because flexbox gap is the next threshold (Chrome 84, Firefox 63, Safari 14.1); without it, the cards touch at every point of the travel, while rotation and corners keep animating.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="cmc-scene" style="--p: 0.0000;">
  <div class="cmc-scroll" id="cmcScroll" tabindex="0" role="region" aria-label="Zone de démonstration au défilement">
    <div class="cmc-track" style="height:340%">
    </div>
  </div>
  <div class="cmc-stage" aria-hidden="true">
    <div class="cmc-row">
      <div class="cmc-card" style="--r:-9deg; --g:linear-gradient(145deg,#22ffaa,#0e7490)">01</div>
      <div class="cmc-card" style="--r:5deg;  --g:linear-gradient(145deg,#7c3aed,#db2777)">02</div>
      <div class="cmc-card" style="--r:11deg; --g:linear-gradient(145deg,#ffd166,#f4a300)">03</div>
    </div>
  </div>
  <span class="cmc-hint">Scrollez ici ↓</span>
</div>
CSS
.cmc-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;
   }

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

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

.cmc-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;
   }

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

.cmc-track  {
   width: 100%;
   }

.cmc-row  {
   position: absolute;
   inset: 0px;
   display: flex;
   gap: calc(34px - var(--p) * 22px);
   align-items: center;
   justify-content: center;
   }

.cmc-card  {
   width: 86px;
   height: 116px;
   display: grid;
   place-items: center;
   background: var(--g);
   color: rgba(255, 255, 255, 0.9);
   font-weight: 800;
   font-size: 1.1rem;
   border-radius: calc(50px - var(--p) * 40px) calc(14px + var(--p) * 0px) calc(46px - var(--p) * 36px) calc(18px - var(--p) * 8px);
   transform: rotate(calc(var(--r) * (1 - var(--p)))) translateY(calc((1 - var(--p)) * 12px));
   box-shadow: rgba(0, 0, 0, 0.45) 0px 10px 24px;
   }
JavaScript (fx-0780)
(function() {
  /* fx-0780 — Corner Morph Cards : progression de scroll → variable CSS --p (rAF, passif) */
  var sc = document.getElementById('cmcScroll');
  if (!sc) return;
  var host = sc.closest('.cmc-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 — .cmc-track height: 340%) 340% (624 px of scrolling for a 260 px window) p goes from 0 to 1 over (height − 100%) of the window. 200% = 260 px of travel, assembly done in three wheel notches; 500% = 1,040 px, slow assembly. It is also the amount of wheel absorbed before the page takes over.
Card gap (CSS — .cmc-row gap: calc(34px − var(--p) * 22px)) 34 → 12 px The two numbers set the travel: each outer card covers the difference (22 px) with three cards, one and a half times with four. calc(90px − var(--p) * 78px) = 78 px of travel; then check that 3 × 86 + 2 × gap fits in the scene, otherwise overflow: hidden clips the cards at rest.
Tilt (HTML — --r on each card) −9deg / 5deg / 11deg Angle at rest, brought back to 0 at the end; alternating signs create the scattered look. 0deg everywhere: only corners and gap move. Beyond ±15°, the boxes of tilted cards widen (measured: 86 → 107 px wide at 11°) and may overlap at rest.
Corners (CSS — .cmc-card border-radius, four calc()) 50 / 14 / 46 / 18 px → 10 / 14 / 10 / 10 px Each corner has its own start and amplitude. The second one is multiplied by 0px and never moves — a leftover: calc(14px − var(--p) * 4px) to end at 10 px everywhere. Flip the signs to go from rectangle to pebble.
Lift (CSS — translateY(calc((1 − var(--p)) * 12px))) 12 px A 12 px descent over the travel, along the tilted card's axis since translateY follows rotate (1.9 px of lateral drift measured for −9°). Put translateY before rotate for a purely vertical lift; 0px to remove it.
Colors (HTML — --g on each card) green #22ffaa → #0e7490, violet #7c3aed → #db2777, gold #ffd166 → #f4a300 One gradient per card, read by background: var(--g). With text, keep contrast across the whole card: violet passes (4.6:1 with white), green and gold do not (2.0 and 1.6:1) — see Accessibility.
Hint (CSS — .cmc-hint opacity: calc(1 − var(--p) * 3)) × 3 (invisible from p = 0.333), 9.6 px * 10 = gone at the first notch; * 1 = fades over the whole travel. Raise font-size to at least 0.7 rem. scrollbar-width: none on .cmc-scroll also hides the thin bar, the only other scrolling cue.
Multiple instances (JS — getElementById('cmcScroll')) a single one (id) For several scenes, replace with document.querySelectorAll('.cmc-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.

FAQ

The cards only move about twenty pixels: how do I get a real assembly?

Because the only displacement is the gap's: gap goes from 34 to 12 px, i.e. 22 px per interval. The row being centered by flexbox, the middle card does not move and each of the two others covers 22 px (measured 19 to 21 px, rotation rounding the boxes) — over 624 px of scrolling. The straightening (−9°, 5°, 11° → 0) and the corners (50 → 10 px) do the rest of the impression. For a real convergence, raise the resting gap: gap: calc(120px − var(--p) * 108px) gives 108 px of travel per outer card, 162 px with four cards (one and a half times the gap). Check the width at rest — 3 × 86 + 2 × 120 = 498 px exceed the 430 px scene, which overflow: hidden would clip — by widening the scene or switching the gap to a percentage of its width (calc(14% − var(--p) * 11%)). The three examples use 44 to 50 px of amplitude on four cards.

Corner Morph Cards or Corner Morph Overlay (fx-0779): same engine, what is the difference?

The script is the same (fifteen lines, only an id and a prefix change) and so is the phantom track (340%, 624 px): in both cases, scrolling produces nothing but a --p variable. Everything lies in what the CSS does with it. fx-0779 spends it in a single property of a single hidden layer — clip-path: circle() — to reveal something from a point; fx-0780 spends it in four properties of three visible elements — gap, rotate, translateY, border-radius — and reveals nothing: everything is there at rest, scrolling tidies it up. Choose fx-0780 when several objects must form a whole (team, ingredients, sensors), fx-0779 when one state must cover another from a place (theme, character, plan). Nothing prevents wiring both rules to the same variable: one script, one scroll, the cards assemble while the background switches.

How do I go to four cards, or put real content in them?

Each .cmc-card is a centered grid (display: grid; place-items: center): replace the number with whatever you want — avatar, name, value, gauge — and give the card its --r and --g inline, alternating the signs of the angles (−9deg / 5deg / −7deg / 11deg) to keep the scattered look. Mind the width at rest: four cards make 4 × 86 + 3 × 34 = 446 px, 16 px more than the scene, and overflow: hidden trims 8 px off the outer cards — bring the gap down to calc(28px − var(--p) * 16px) or the cards to 80 px. For text, follow the contrast figures of the Accessibility section (white passes neither on green nor on gold); and if the content has meaning, also write it in the scrolling track, the only part of the scene a screen reader reaches. The three examples above are built this way, with four cards each.