Concentric Rings Morph
Five SVG circles of radius 14 to 58, drawn clockwise from twelve o'clock by a stroke-dasharray equal to their circumference times --p; over 624 px of ghost scrolling the oklch hue rotates by 120°, the figure grows from 139 to 193 px and each ring turns 18° further than the previous one — 681 bytes of JavaScript, nothing that depends on animation-timeline.
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 .crm-scene (430 × 260 px, overflow: hidden, --p: 0) stacks the same skeleton as fx-0778, fx-0779 and fx-0780: on top, .crm-scroll (z-index: 3, overflow-y: auto, scrollbar-width: thin) holds nothing but an empty .crm-track block 340% tall — 884 px of content for a 260 px window, i.e. 624 px of ghost scrolling; underneath, .crm-stage (z-index: 1, pointer-events: none, aria-hidden) carries a 200 × 200 px SVG with viewBox 0 0 120 120 — one unit is 1.667 px — holding five <circle> elements of radius 14, 25, 36, 47 and 58 (a step of 11), each carrying its index --i from 0 to 4, with fill: none, stroke-width: 5, stroke-linecap: round; above them, .crm-pct (centered grid, 16 px, weight 800) shows attr(data-pct) in its ::after, and .crm-hint “Scrollez ici ↓” (9.6 px) 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 is 19 lines of ES5: getElementById('crmScroll'), closest('.crm-scene'), a passive scroll listener that only books one frame through requestAnimationFrame (measured: 30 events in the same frame → a single write), and update(), which computes p = scrollTop / (scrollHeight − clientHeight), writes it to four decimals into --p on the scene and sets Math.round(p × 100) + '%' in data-pct. With the wheel in 100 px notches: p advances by 0.1603 per notch, the course is done on the 7th, eight writes in all — identical in Chromium, Firefox and WebKit, without a single console error.
The drawing lives in one declaration: stroke-dasharray: calc((88 + var(--i) × 69) × var(--p) × 1px) 1000px. In an SVG, px is the user unit of the viewBox, and 88 + 69 × i is the circumference of each circle to the unit (2π × 14 = 87.96 … 2π × 58 = 364.42: the outer ring falls 0.42 unit short, which the round caps of 2.5 units radius close); the 1,000 gap exceeds any circumference, so there is never more than one dash per circle. At p, the dash measures p times the circumference: the five heads advance at the same angular speed, 360° over 624 px, 0.58° per scrolled pixel, whatever the radius — measured at p = 0.5, the inner ring is lit from 0° to 192° and the outer one from 36° to 216° (6° sampling, round caps included). A <circle> path starts at 3 o'clock and runs clockwise; transform: rotate(calc(−90deg + var(--i) × 18deg × var(--p))) around 60px 60px brings the start to twelve and turns ring i by 18° × i × p: the tails fan out (the outer ring's tail sits at 18° at p = 0.25, 36° at 0.5, 72° at 1), a spread that vanishes once the rings close. scale(calc(.72 + var(--p) × .28)) grows the figure from 139 to 193 px in diameter and opacity: calc(.35 + var(--p) × .65) brightens it. At rest, the dash has zero length but the round caps remain: a 6 px dot at twelve o'clock on each of the five rings, five dots stacked above the counter (measured: lit pixels at the top of every circle at p = 0, rgb(61,58,130) for the inner one, 1.8 to 2.0:1 on the background).
stroke: oklch(75% .26 calc(280 + var(--i) × 26 + var(--p) × 120)) fixes lightness (75%) and chroma (.26) and only rotates the hue: 280 + 26 × i at rest — 280 (violet), 306, 332, 358 and 24 (red-orange) — plus 120° over the course, i.e. 40, 66, 92, 118 and 144 at the end, from orange to green. A chroma of .26 at 75% lightness is outside the sRGB gamut for all of these hues: the browser clips, and the colors measured in Chromium at p = 1 — rgb(255,89,0), rgb(255,124,0), rgb(233,160,0), rgb(163,191,0), rgb(0,212,17) — carry the mark of that clip (components at 255 or 0); on a P3 display they will be more saturated and slightly different. Three measured limits. The outer edge of ring 4 sits 60.5 units from the center (58 + half-stroke 2.5) in a viewBox of 60: the SVG, with overflow: hidden, clips 0.8 px at p = 1 (pixel lit 100 px from the center, background at 101). The script targets a single id: a second copy of the HTML stays at p = 0 (measured: scrollTop 300, --p 0.0000, counter “0%”, and the document carries two id="crmScroll"). Finally, oklch() has no fallback: on a browser that cannot read it, the whole declaration is rejected, stroke falls back to none and no ring appears at all — measured: an invalid stroke declaration yields to the previous one when there is one, so a line stroke: hsl(calc(280 + var(--i) × 26 + var(--p) × 120), 90%, 65%) placed before it renders rgb(193,85,246) where oklch() is missing.
Accessibility
- prefers-reduced-motion not handled — measured under
reducein Chromium, Firefox and WebKit: atscrollTop312, p = 0.5, dashes, rotation and scale identical to a visitor without the preference. The motion follows the wheel or the finger, it is not an autonomous animation, but three transformations run at once (drawing, 18° rotation per ring, 39% growth) on a 200 px figure. A fallback that keeps the information and removes the stray motion:@media (prefers-reduced-motion: reduce) { .crm-ring { transform: rotate(-90deg) } }— the rings still draw, without turning or growing. - Keyboard verified, screen reader to complete.
tabindex="0"makes the zone focusable: Tab reaches it, Arrow Down scrolls 40 px per press in Chromium (three presses → p = 0.19) and 48 px in Firefox, End sends to p = 1, and:focus-visibledraws a 2 pxrgb(167,139,250)outline inside the frame (7.1:1). In WebKit, measured: the first Arrow Down after Tab is ignored, the second scrolls 40 px. But the region is empty for speech output:role="region"and a Frencharia-labelannounce a frame with no content (the track is an emptydiv), and the counter lives in thearia-hiddenlayer — the percentage is never announced. If the number matters (fundraising, level), also write it in the track, visually hidden, or in anoutput aria-live="polite"updated byupdate(); and translate thearia-label. - Measured contrasts on the
rgb(13,13,22)background: counterrgb(231,229,255)16 px = 15.7:1; hintrgb(143,143,176)= 6.2:1 but 9.6 px (0.6rem), to be raised to 11–12 px. The rings are graphical elements (3:1 threshold) and their opacity follows p: at p = 1, from 6.2:1 (orange) to 9.6:1 (green); at p = 0.5 (opacity 0.675), 4.0 to 4.4:1; at p = 0.25, 2.7 to 2.8:1, and the resting dots 1.8 to 2.0:1 — the first quarter of the drawing is below 3:1. On a white background the final colors do not pass either (2.0 to 3.1:1). If the ring carries information, raise the opacity floor from.35to.6and, on a light background, the lightness from75%to60%. - Touch and cohabitation with the page — measured in mobile emulation: a 250 px swipe scrolls the zone (p = 0.40), the effect works by finger. The zone absorbs 624 px of wheel before handing over: in Chromium and WebKit, the notch after p = 1 moves the page by 100 px; Firefox keeps the wheel inside the frame as long as the gesture is continuous (measured: two more notches 120 ms apart, page still) and releases it after a pause. No trap, but 624 px of wheel for a 260 px scene: reduce
height: 340%if the scene is only an indicator, and do not addoverscroll-behavior: contain, which would block a user whose pointer rests on the frame.
Browser compatibility
Requires oklch() with a calc() in the hue — the most recent requirement: Chrome 111, Firefox 113, Safari 15.4 —, the inset shorthand, CSS transforms on SVG elements with a px transform-origin, calc() expressions that multiply a variable by 1px and by 1deg, attr() in content, closest(), requestAnimationFrame and passive listeners; scrollbar-width: thin is only read by Chrome 121+ and Safari 18.2+ (standard scrollbar before). Zero dependencies, 681 bytes of ES5 JavaScript. The progress is read in JavaScript (scrollTop), not through animation-timeline: scroll(): the effect works in Firefox, which animates no CSS-native scroll timeline — for drawing a figure on scroll, that is what sets it apart from Path Drawing on Scroll (fx-0572), built on animation-timeline and inert in that browser.
Without JavaScript, --p stays at 0 (the style attribute of the sold HTML sets it): five dots at twelve o'clock at 35% opacity, “0%” in the center, the hint, and scrolling draws nothing. Without oklch() (Chrome ≤ 110, Firefox ≤ 112, Safari ≤ 15.3), the stroke declaration is rejected and the rings do not exist: only the counter moves — add the hsl() line described above. Without inset (Safari ≤ 14.0, Chrome ≤ 86, Firefox ≤ 65), the absolute layers have no dimensions: a 0 × 0 scroll zone, dead effect; top: 0; right: 0; bottom: 0; left: 0 would bring them back.
The code
Copy the three blocks into your page. No dependencies.
<div class="crm-scene" style="--p: 0.0000;">
<div class="crm-scroll" id="crmScroll" tabindex="0" role="region" aria-label="Zone de démonstration au défilement">
<div class="crm-track" style="height:340%">
</div>
</div>
<div class="crm-stage" aria-hidden="true">
<svg class="crm-svg" viewBox="0 0 120 120" aria-hidden="true">
<circle class="crm-ring" style="--i:0" cx="60" cy="60" r="14">
</circle>
<circle class="crm-ring" style="--i:1" cx="60" cy="60" r="25">
</circle>
<circle class="crm-ring" style="--i:2" cx="60" cy="60" r="36">
</circle>
<circle class="crm-ring" style="--i:3" cx="60" cy="60" r="47">
</circle>
<circle class="crm-ring" style="--i:4" cx="60" cy="60" r="58">
</circle>
</svg>
<span class="crm-pct" data-pct="0%">
</span>
</div>
<span class="crm-hint">Scrollez ici ↓</span>
</div>
.crm-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;
}
.crm-scroll {
position: absolute;
inset: 0px;
overflow-y: auto;
z-index: 3;
scrollbar-width: thin;
}
.crm-stage {
position: absolute;
inset: 0px;
z-index: 1;
pointer-events: none;
}
.crm-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;
}
.crm-scroll:focus-visible {
outline: rgb(167, 139, 250) solid 2px;
outline-offset: -2px;
}
.crm-track {
width: 100%;
}
.crm-svg {
position: absolute;
inset: 0px;
margin: auto;
width: 200px;
height: 200px;
}
.crm-ring {
fill: none;
stroke-width: 5;
stroke-linecap: round;
stroke: oklch(75% .26 calc(280 + var(--i) * 26 + var(--p) * 120));
stroke-dasharray: calc((88 + var(--i) * 69) * var(--p) * 1px) 1000px;
transform-origin: 60px 60px;
transform: rotate(calc(-90deg + var(--i) * 18deg * var(--p))) scale(calc(.72 + var(--p) * .28));
opacity: calc(.35 + var(--p) * .65);
}
.crm-pct {
position: absolute;
inset: 0px;
display: grid;
place-items: center;
font-size: 1rem;
font-weight: 800;
color: rgb(231, 229, 255);
letter-spacing: 0.08em;
}
.crm-pct::after {
content: attr(data-pct);
}
(function() {
/* fx-0781 — Concentric Rings Morph : progression de scroll → variable CSS --p (rAF, passif) */
var sc = document.getElementById('crmScroll');
if (!sc) return;
var host = sc.closest('.crm-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));
var pct = host.querySelector('.crm-pct');
if (pct) pct.setAttribute('data-pct', Math.round(p * 100) + '%');
}
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 |
|---|---|---|
Course length (HTML — .crm-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, three wheel notches; 500% = 1,040 px, slow drawing. It is also the number of wheel pixels absorbed before the page takes over. |
Radii and lengths (HTML — r="14" … r="58"; CSS — 88 + var(--i) × 69) |
14, 25, 36, 47, 58 / 88 + 69 × i | The dash length must stay 2πr, otherwise the ring stays open (too short) or closes before the end of the course (too long). For four rings of radius 20, 32, 44, 56: 126 + var(--i) × 75. Radius 58 is the maximum: beyond it, the stroke leaves the 120 viewBox and gets clipped. |
Hue (CSS — oklch(75% .26 calc(280 + var(--i) × 26 + var(--p) × 120))) |
base 280°, 26° step per ring, 120° pivot over the course | Step 0 = five rings of the same color; pivot 0 = fixed color. Chroma .26 is outside the sRGB gamut (clipped colors, different on P3): .18 gives the same hues on every screen. On a light background, lightness 60%. |
Rotation, scale, opacity (CSS — 18deg, .72 + var(--p) × .28, .35 + var(--p) × .65) |
18° per ring, 72 → 100%, opacity .35 → 1 | 0deg: all tails stay at twelve, a strictly concentric drawing. 1 + var(--p) × 0: the figure keeps 193 px from start to finish. Opacity 0 + var(--p) × 1: no more dots at twelve at rest, the rings are born as they draw; floor .6: 3:1 from the first quarter of the drawing. |
Successive tiers (CSS — replace var(--p) with clamp(0, var(--p) × 5 − var(--i), 1)) |
all together | Ring i only draws between p = i/5 and (i + 1)/5. Measured at p = 0.5 in the three engines: rings 0 and 1 closed (88 and 157), ring 2 halfway (113 of 226), 3 and 4 empty. Multiply p by a goal fraction to stop the filling before the last ring — that is the variant used by the three examples. |
Counter (JS — Math.round(p × 100) + '%'; CSS — .crm-pct) |
0% → 100%, inherited font | No font-family: on a blank page the number renders in Times (measured). Set the site's font on .crm-pct, and format whatever you want in update() — amount, points, step — or hide it (display: none). |
Smoothing between notches (CSS — to add on .crm-ring) |
none: each wheel notch jumps by 16% | transition: stroke-dasharray .3s, transform .3s, stroke .3s, opacity .3s interpolates between two writes of --p — measured in the three engines: the length passes through intermediate values (55.8 → 233 over 0.8 s) — at the cost of a slight lag behind the finger. |
Multiple instances (JS — getElementById('crmScroll')) |
a single one (id) | Replace it with document.querySelectorAll('.crm-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
Why do five dots show above the center before any scrolling?
Because at p = 0 the dash measures 0px but stroke-linecap: round adds a half-disc at each end: a zero-length dash becomes a dot the diameter of the stroke — 5 units, i.e. 6 px at the resting scale (.72) — placed at the start of the path, brought to twelve o'clock by the −90° rotation. Measured at p = 0: lit pixel at the top of all five circles, stacked vertically above the counter, at 35% opacity (1.8 to 2.0:1). The same round caps are what closes the outer ring, whose length of 364 falls 0.42 unit short. Three ways to handle them: keep them as a seed (they show where the drawing will be born); hide them by setting the opacity to calc(var(--p) × 1), so the rings appear as they draw; or switch to stroke-linecap: butt, at the cost of square heads and an outer ring left open by 0.7 px.
Concentric Rings Morph or Corner Morph Overlay (fx-0779): same script, what is the difference?
The engine is identical — the same 19-line IIFE down to the prefix, the same 340% ghost track (624 px), the same --p variable; this one additionally writes a percentage into data-pct. Everything happens in what the CSS does with it. fx-0779 spends p on a single property, clip-path: circle(), to reveal a layer that already exists in full under the first one: at the end nothing has been drawn, something has been uncovered, from a point that means something (a button, a corner). fx-0781 spends p on four properties of five <circle> elements — dash length, rotation, scale, hue — to build a figure that does not exist at rest: at p = 0 there are only five dots and “0%”, at p = 1 a complete target and “100%”. The first tells a place, the second a quantity. Choose fx-0781 when the number matters (coverage, fundraising, level) and fx-0779 when the origin matters. If what you want is an arbitrary path drawing itself, Path Drawing on Scroll (fx-0572) draws a <path> with stroke-dashoffset — but through animation-timeline, hence not in Firefox.
My campaign is at 68%: how do I stop the rings before the end, one tier after another?
Two CSS changes and one line of JS. Replace var(--p) in stroke-dasharray (and in the opacity if empty rings should stay discreet) with clamp(0, var(--p) × 5 × .68 − var(--i), 1): ring i draws between p = i / 3.4 and (i + 1) / 3.4, and at the end of the course the first three rings are closed, the fourth at 40%, the fifth empty — five rings, five 20% tiers. Measured with the factor 5 alone, at p = 0.5: 88, 157, 113 (of 226), 0, 0 in Chromium, Firefox and WebKit. For the counter, replace Math.round(p × 100) with Math.round(p × 68) in update() — or with an amount: Math.round(p × 34200).toLocaleString('en-US') + ' €'. Add a static grey circle behind each ring (same r, no --i) to show the remaining tiers, and lower the opacity floor to .2 so that the twelve o'clock dots of the empty rings do not take over.