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.
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
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-visibledraws a 2 px violet outline inside the frame. But the region is empty for a screen reader:role="region"andaria-label="Zone de démonstration au défilement"— shipped in French, to be translated — announce a frame whose track is an emptydiv, and the cards sit in anaria-hiddenblock. 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,#3b2a00gives 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, thescrollbar-width: thinbar on the right edge — on macOS it only appears while scrolling. Keep the scene short or reduceheight: 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.
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.
<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>
.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;
}
(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 / property | Default | Effect |
|---|---|---|
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.