Concentric Rings Morph
Cinq cercles SVG de rayon 14 à 58, tracés depuis midi dans le sens horaire par un stroke-dasharray égal à leur circonférence multipliée par --p ; sur 624 px de défilement fantôme, la teinte oklch tourne de 120°, la figure passe de 139 à 193 px et chaque anneau pivote de 18° de plus que le précédent — 681 octets de JavaScript, rien qui dépende d'animation-timeline.
Cet effet est gratuit — la catégorie Scroll en contient 70 au total, dont 12 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →
Exemples d'usage
Comment ça marche
La scène .crm-scene (430 × 260 px, overflow: hidden, --p: 0) empile le même squelette que fx-0778, fx-0779 et fx-0780 : au-dessus, .crm-scroll (z-index: 3, overflow-y: auto, scrollbar-width: thin) ne contient qu'un bloc vide .crm-track haut de 340% — 884 px de contenu pour 260 px de fenêtre, soit 624 px de défilement fantôme ; dessous, .crm-stage (z-index: 1, pointer-events: none, aria-hidden) porte un SVG de 200 × 200 px en viewBox 0 0 120 120 — une unité vaut 1,667 px — avec cinq <circle> de rayon 14, 25, 36, 47 et 58 (un pas de 11), chacun portant son indice --i de 0 à 4, en fill: none, stroke-width: 5, stroke-linecap: round ; par-dessus, .crm-pct (grille centrée, 16 px, graisse 800) affiche attr(data-pct) dans son ::after, et .crm-hint « Scrollez ici ↓ » (9,6 px) s'efface en opacity: calc(1 − var(--p) × 3) — mesuré 0,52 à p = 0,16, 0,04 à 0,32, 0 dès 0,333. Le script fait 19 lignes ES5 : getElementById('crmScroll'), closest('.crm-scene'), un écouteur scroll passif qui ne fait que réserver une image par requestAnimationFrame (mesuré : 30 événements dans la même image → une seule écriture), et update() qui calcule p = scrollTop / (scrollHeight − clientHeight), l'écrit à quatre décimales dans --p sur la scène et pose Math.round(p × 100) + '%' dans data-pct. À la molette par crans de 100 px : p avance de 0,1603 par cran, la course est bouclée au 7ᵉ, huit écritures en tout — identiques dans Chromium, Firefox et WebKit, sans une erreur console.
Le tracé tient dans une déclaration : stroke-dasharray: calc((88 + var(--i) × 69) × var(--p) × 1px) 1000px. Dans un SVG, px est l'unité utilisateur du viewBox, et 88 + 69 × i est la circonférence de chaque cercle à l'unité près (2π × 14 = 87,96 … 2π × 58 = 364,42 : l'anneau extérieur manque de 0,42 unité, que les bouts ronds de 2,5 unités de rayon referment) ; l'espace de 1 000 dépasse toute circonférence, il n'y a donc jamais qu'un seul trait par cercle. À p, le trait mesure p fois la circonférence : les cinq têtes avancent à la même vitesse angulaire, 360° sur 624 px, 0,58° par pixel de défilement, quel que soit le rayon — mesuré à p = 0,5, l'anneau intérieur est allumé de 0° à 192° et l'extérieur de 36° à 216° (échantillonnage à 6°, bouts ronds compris). Le chemin d'un <circle> commence à 3 h et tourne dans le sens horaire ; transform: rotate(calc(−90deg + var(--i) × 18deg × var(--p))) autour de 60px 60px ramène le départ à midi et fait pivoter l'anneau i de 18° × i × p : les queues s'écartent en éventail (celle de l'anneau extérieur est à 18° à p = 0,25, 36° à 0,5, 72° à 1), écart invisible une fois les anneaux fermés. scale(calc(.72 + var(--p) × .28)) fait grandir la figure de 139 à 193 px de diamètre et opacity: calc(.35 + var(--p) × .65) l'éclaircit. Au repos, le trait est de longueur nulle mais les bouts ronds subsistent : un point de 6 px à midi sur chacun des cinq anneaux, cinq points empilés au-dessus du compteur (mesuré : pixels allumés au sommet de chaque cercle à p = 0, rgb(61,58,130) pour l'intérieur, 1,8 à 2,0:1 sur le fond).
stroke: oklch(75% .26 calc(280 + var(--i) × 26 + var(--p) × 120)) fixe la clarté (75 %) et la chroma (.26) et ne fait tourner que la teinte : 280 + 26 × i au repos — 280 (violet), 306, 332, 358 et 24 (rouge orangé) — plus 120° sur la course, soit 40, 66, 92, 118 et 144 à l'arrivée, de l'orange au vert. Une chroma de .26 à 75 % de clarté sort du gamut sRGB pour toutes ces teintes : le navigateur écrête, et les couleurs mesurées dans Chromium à p = 1 — rgb(255,89,0), rgb(255,124,0), rgb(233,160,0), rgb(163,191,0), rgb(0,212,17) — portent la trace de ce clip (composantes à 255 ou 0) ; sur un écran P3 elles seront plus saturées et légèrement différentes. Trois limites mesurées. Le bord externe de l'anneau 4 est à 60,5 unités du centre (58 + demi-trait 2,5) pour un viewBox de 60 : le SVG, en overflow: hidden, rogne 0,8 px à p = 1 (pixel allumé à 100 px du centre, fond à 101). Le script vise un id unique : une seconde copie du HTML reste à p = 0 (mesuré : scrollTop 300, --p 0.0000, compteur « 0% », et le document porte deux id="crmScroll"). Enfin oklch() n'a aucun repli : sur un navigateur qui ne le lit pas, la déclaration entière est rejetée, stroke retombe à none et aucun anneau n'apparaît — mesuré : une déclaration stroke invalide laisse la place à la précédente quand il y en a une, donc une ligne stroke: hsl(calc(280 + var(--i) × 26 + var(--p) × 120), 90%, 65%) placée avant rend rgb(193,85,246) là où oklch() manque.
Accessibilité
- prefers-reduced-motion absent du code — mesuré sous
reducedans Chromium, Firefox et WebKit : àscrollTop312, p = 0,5, traits, rotation et échelle identiques à ceux d'un visiteur sans préférence. Le mouvement suit la molette ou le doigt, ce n'est pas une animation autonome, mais trois transformations tournent en même temps (tracé, rotation de 18° par anneau, agrandissement de 39 %) sur une figure de 200 px. Repli qui garde l'information et retire le mouvement parasite :@media (prefers-reduced-motion: reduce) { .crm-ring { transform: rotate(-90deg) } }— les anneaux se tracent encore, sans pivoter ni grandir. - Clavier vérifié, lecteur d'écran à compléter.
tabindex="0"rend la zone focalisable : Tab l'atteint, Flèche bas défile de 40 px par pression dans Chromium (trois pressions → p = 0,19) et de 48 px dans Firefox, Fin envoie à p = 1, et:focus-visibledessine un liserérgb(167,139,250)de 2 px à l'intérieur du cadre (7,1:1). Dans WebKit, mesuré : la première Flèche bas après Tab est ignorée, la deuxième défile de 40 px. Mais la région est vide pour la synthèse vocale :role="region"et unaria-labelen français annoncent un cadre sans contenu (la piste est undivvide), et le compteur vit dans le calquearia-hidden— le pourcentage n'est jamais annoncé. Si le chiffre compte (collecte, niveau), écrivez-le aussi dans la piste, masqué visuellement, ou dans unoutput aria-live="polite"mis à jour parupdate(); et traduisez l'aria-label. - Contrastes mesurés sur le fond
rgb(13,13,22): compteurrgb(231,229,255)16 px = 15,7:1 ; indicationrgb(143,143,176)= 6,2:1 mais 9,6 px (0.6rem), à remonter à 11-12 px. Les anneaux sont des éléments graphiques (seuil 3:1) et leur opacité suit p : à p = 1, de 6,2:1 (orange) à 9,6:1 (vert) ; à p = 0,5 (opacité 0,675), 4,0 à 4,4:1 ; à p = 0,25, 2,7 à 2,8:1, et les points de repos 1,8 à 2,0:1 — le premier quart du tracé est sous 3:1. Sur fond blanc, les couleurs finales ne tiennent pas non plus (2,0 à 3,1:1). Si l'anneau porte une information, relevez le plancher d'opacité de.35à.6et, sur fond clair, la clarté75%à60%. - Tactile et cohabitation avec la page — mesuré en émulation mobile : un geste de 250 px défile la zone (p = 0,40), l'effet marche au doigt. La zone absorbe 624 px de molette avant de rendre la main : dans Chromium et WebKit, le cran qui suit p = 1 fait avancer la page de 100 px ; Firefox retient la molette dans le cadre tant que le geste est continu (mesuré : deux crans de plus à 120 ms d'intervalle, page immobile) et la rend après une pause. Pas de piège, mais 624 px de rouleau pour une scène de 260 px : réduisez
height: 340%si la scène n'est qu'un indicateur, et n'ajoutez pasoverscroll-behavior: contain, qui bloquerait l'utilisateur dont le pointeur repose sur le cadre.
Compatibilité navigateur
Requiert oklch() avec un calc() dans la teinte — l'exigence la plus récente : Chrome 111, Firefox 113, Safari 15.4 —, le raccourci inset, les transformations CSS sur éléments SVG avec transform-origin en px, des calc() qui multiplient une variable par 1px et par 1deg, attr() dans content, closest(), requestAnimationFrame et les écouteurs passifs ; scrollbar-width: thin n'est lu que par Chrome 121+ et Safari 18.2+ (barre standard avant). Zéro dépendance, 681 octets de JavaScript ES5. La progression est lue en JavaScript (scrollTop), pas par animation-timeline: scroll() : l'effet fonctionne dans Firefox, qui n'anime aucune timeline de défilement en CSS natif — pour tracer une figure au défilement, c'est ce qui le sépare de Path Drawing on Scroll (fx-0572), bâti sur animation-timeline et inerte dans ce navigateur.
Sans JavaScript, --p reste à 0 (l'attribut style du HTML vendu le fixe) : cinq points à midi à 35 % d'opacité, « 0% » au centre, l'indication, et le défilement ne trace rien. Sans oklch() (Chrome ≤ 110, Firefox ≤ 112, Safari ≤ 15.3), la déclaration stroke est rejetée et les anneaux n'existent pas : seul le compteur bouge — ajoutez la ligne hsl() décrite plus haut. Sans inset (Safari ≤ 14.0, Chrome ≤ 86, Firefox ≤ 65), les couches absolues n'ont plus de dimensions : zone de défilement de 0 × 0, effet mort ; top: 0; right: 0; bottom: 0; left: 0 les rendrait.
Le code
Copiez les trois blocs dans votre page. Aucune dépendance.
<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();
})();
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Longueur de la course (HTML — .crm-track height: 340%) |
340 % (624 px de défilement pour 260 px de fenêtre) | p va de 0 à 1 sur (hauteur − 100 %) de la fenêtre : 200% = 260 px, trois crans de molette ; 500% = 1 040 px, tracé lent. C'est aussi le nombre de pixels de molette absorbés avant que la page reprenne la main. |
Rayons et longueurs (HTML — r="14" … r="58" ; CSS — 88 + var(--i) × 69) |
14, 25, 36, 47, 58 / 88 + 69 × i | La longueur du trait doit rester 2πr, sinon l'anneau reste ouvert (trop court) ou se referme avant la fin de course (trop long). Pour quatre anneaux de rayon 20, 32, 44, 56 : 126 + var(--i) × 75. Le rayon 58 est le maximum : au-delà, le trait sort du viewBox de 120 et se fait rogner. |
Teinte (CSS — oklch(75% .26 calc(280 + var(--i) × 26 + var(--p) × 120))) |
base 280°, écart 26° par anneau, pivot 120° sur la course | Écart 0 = cinq anneaux de même couleur ; pivot 0 = couleur fixe. Chroma .26 sort du gamut sRGB (couleurs écrêtées, différentes sur P3) : .18 donne les mêmes teintes sur tous les écrans. Sur fond clair, clarté 60%. |
Rotation, échelle, opacité (CSS — 18deg, .72 + var(--p) × .28, .35 + var(--p) × .65) |
18° par anneau, 72 → 100 %, opacité .35 → 1 | 0deg : les queues restent toutes à midi, tracé strictement concentrique. 1 + var(--p) × 0 : la figure garde 193 px du début à la fin. Opacité 0 + var(--p) × 1 : plus de points à midi au repos, les anneaux naissent en se traçant ; plancher .6 : 3:1 dès le premier quart du tracé. |
Paliers successifs (CSS — remplacer var(--p) par clamp(0, var(--p) × 5 − var(--i), 1)) |
tous ensemble | L'anneau i ne se trace qu'entre p = i/5 et (i + 1)/5. Mesuré à p = 0,5 dans les trois moteurs : anneaux 0 et 1 fermés (88 et 157), anneau 2 à mi-course (113 sur 226), 3 et 4 vides. Multipliez p par une fraction d'objectif pour arrêter le remplissage avant le dernier anneau — c'est la variante des trois exemples. |
Compteur (JS — Math.round(p × 100) + '%' ; CSS — .crm-pct) |
0% → 100%, police héritée | Aucune font-family : sur une page vierge, le chiffre s'affiche en Times (mesuré). Posez la police du site sur .crm-pct, et formatez ce que vous voulez dans update() — montant, points, étape — ou masquez-le (display: none). |
Lissage entre crans (CSS — à ajouter sur .crm-ring) |
aucun : chaque cran de molette saute de 16 % | transition: stroke-dasharray .3s, transform .3s, stroke .3s, opacity .3s interpole entre deux écritures de --p — mesuré dans les trois moteurs : la longueur passe par des valeurs intermédiaires (55,8 → 233 en 0,8 s) — au prix d'un léger retard sur le doigt. |
Instances multiples (JS — getElementById('crmScroll')) |
une seule (id) | Remplacez par document.querySelectorAll('.crm-scroll').forEach(function (sc) { … }) et retirez l'id, ou dupliquez l'IIFE avec un id par scène — c'est ce que font les exemples 2 et 3. |
FAQ
Pourquoi cinq points s'affichent-ils au-dessus du centre avant tout défilement ?
Parce qu'à p = 0 le trait mesure 0px mais que stroke-linecap: round ajoute un demi-disque à chaque extrémité : un trait de longueur nulle devient un point du diamètre du trait — 5 unités, soit 6 px à l'échelle de repos (.72) — posé au départ du chemin, ramené à midi par la rotation de −90°. Mesuré à p = 0 : pixel allumé au sommet des cinq cercles, empilés verticalement au-dessus du compteur, à 35 % d'opacité (1,8 à 2,0:1). Ce sont aussi ces bouts ronds qui referment l'anneau extérieur, dont la longueur 364 manque de 0,42 unité. Trois façons de les traiter : les garder comme amorce (ils montrent où le tracé va naître) ; les cacher en passant l'opacité à calc(var(--p) × 1), les anneaux apparaissent alors en se traçant ; ou passer en stroke-linecap: butt, au prix de têtes carrées et d'un anneau extérieur ouvert de 0,7 px.
Concentric Rings Morph ou Corner Morph Overlay (fx-0779) : même script, quelle différence ?
Le moteur est identique — la même IIFE de 19 lignes au préfixe près, la même piste fantôme à 340 % (624 px), la même variable --p ; celui-ci écrit en plus un pourcentage dans data-pct. Tout se joue dans ce que le CSS en fait. fx-0779 dépense p dans une seule propriété, clip-path: circle(), pour révéler un calque qui existe déjà en entier sous le premier : à la fin, rien n'a été dessiné, quelque chose a été découvert, depuis un point qui a un sens (un bouton, un coin). fx-0781 dépense p dans quatre propriétés de cinq <circle> — longueur de trait, rotation, échelle, teinte — pour construire une figure qui n'existe pas au repos : à p = 0 il n'y a que cinq points et « 0% », à p = 1 une cible complète et « 100% ». Le premier raconte un lieu, le second une quantité. Choisissez fx-0781 quand le chiffre compte (couverture, collecte, niveau) et fx-0779 quand l'origine compte. Si c'est un chemin quelconque que vous voulez voir se tracer, Path Drawing on Scroll (fx-0572) dessine un <path> en stroke-dashoffset — mais par animation-timeline, donc pas dans Firefox.
Ma campagne est à 68 % : comment arrêter les anneaux avant le bout, un palier après l'autre ?
Deux changements CSS et une ligne de JS. Remplacez var(--p) dans stroke-dasharray (et dans l'opacité si les anneaux vides doivent rester discrets) par clamp(0, var(--p) × 5 × .68 − var(--i), 1) : l'anneau i se trace entre p = i / 3,4 et (i + 1) / 3,4, et en fin de course les trois premiers anneaux sont fermés, le quatrième à 40 %, le cinquième vide — cinq anneaux, cinq tranches de 20 %. Mesuré avec le facteur 5 seul, à p = 0,5 : 88, 157, 113 (sur 226), 0, 0 dans Chromium, Firefox et WebKit. Pour le compteur, remplacez Math.round(p × 100) par Math.round(p × 68) dans update() — ou par un montant : Math.round(p × 34200).toLocaleString('fr-FR') + ' €'. Ajoutez derrière chaque anneau un cercle statique gris (même r, sans --i) pour montrer les tranches restantes, et reculez le plancher d'opacité à .2 pour que les points de midi des anneaux vides ne prennent pas le dessus.