Text Highlight on Scroll
Soixante-quatre mots à 15 % d'opacité qu'un écouteur scroll allume un à un : index = ⌊scrollTop ÷ course × 64⌋, soit 3,4 px de défilement par mot dans un bloc de 280 px, fondu de 300 ms, le mot courant en violet. Rien n'est allumé avant le premier scroll, et à cette hauteur les mots s'allument surtout après être sortis du cadre.
Vous aurez accès à la démo interactive avec un compte gratuit.
Cet effet fait partie d'Effect.Labs — 811 effets vanilla, une partie gratuite, une partie premium. Scroll comprend 70 effets, dont 12 gratuits. Découvrir la catégorie →
3 exemples d'usage



Comment ça marche
Le HTML vendu est un bloc .scroll-container#highlight-scroll de 280px de haut, overflow: hidden auto et scroll-behavior: smooth, qui contient .highlight-container#highlight-text (padding: 24px 20px) avec 64 span.highlight-word en ligne — .95rem (15,2 px), graisse 500, line-height: 2 (30,4 px) —, puis un espaceur <div style="height: 300px"> et une étiquette .scroll-hint-label « Scrollez pour explorer » qui pulse de .3 à .7 d'opacité en 2 s (@keyframes pulseHint). Trois états de couleur, avec transition: color .3s : éteint rgba(255,255,255,.15), .lit blanc, .accent #a855f7. Aucune variable CSS, aucune dépendance ; le texte vendu est écrit sans accents (« defilement », « revele », « cree »).
Le script (12 lignes, un seul écouteur scroll sur le bloc) calcule à chaque événement t = scrollTop / (scrollHeight − clientHeight) puis g = Math.floor(t × 64) : les mots d'index inférieur à g reçoivent lit, le mot g reçoit accent, tous les autres sont remis à zéro. Mesuré dans Chromium sur un bloc de 784 px de large : 5 lignes, scrollHeight 500 px, course de 220 px, soit 3,44 px de défilement par mot ; un cran de molette de 40 px bascule 11 à 12 mots d'un coup, que le fondu de 300 ms étale en vague. La granularité est le pixel entier, pas le sous-pixel : scrollTop = 10.11 se relit 10 et 10.51 se relit 11, en DPR 1 comme en DPR 2. À t = 1, g = 64 : tout est allumé et l'accent disparaît. Aucun calcul initial : avant le premier scroll, 0 mot allumé, 0 accent. Et comme scroll-behavior: smooth s'applique aussi aux écritures de scrollTop, une position posée par script est atteinte en animation — relue juste après l'écriture, c'est l'ancienne valeur.
L'index suit la course, pas la géométrie du texte : avec l'espaceur de 300 px, la course (220 px) dépasse la hauteur du texte (200 px). Mesuré à 784 px de large, position par position : le mot accentué n'est dans le cadre que pour 76 des 221 positions, toujours entre −17 et +29 px du bord haut ; il en sort à 47 px de défilement (13ᵉ mot), n'y revient plus après 107 px, et dès 80 px plus aucun mot allumé n'est visible — seules les lignes encore éteintes restent à l'écran, et les 52 derniers pixels de course défilent sans un mot dans le cadre. À 390 px de large (10 lignes, course 372 px, 5,8 px par mot) : 178 positions sur 373, sortie à 108 px. Autrement dit, à 280 px de haut, les mots s'allument surtout après être sortis du cadre ; l'effet tient sa promesse dans un bloc nettement plus haut que le texte, ou avec la course calée sur la hauteur du texte (cf. Personnaliser). Le code est aussi mono-instance : const au niveau global (collé deux fois : SyntaxError: Identifier 'highlightScroll' has already been declared), getElementById unique et querySelectorAll sur toute la page — avec deux blocs, mesuré, le premier est entièrement allumé à mi-course (64 mots sur 128 comptés) et le second reste inerte. Rien n'est jamais retiré, mais il n'y a rien à arrêter : pas de boucle, un écouteur sur un élément.
Accessibilité
- prefers-reduced-motion absent du code : mesuré sous émulation, la transition de 300 ms et la pulsation de 2 s de l'étiquette continuent. Le mouvement est piloté par le lecteur (rien ne bouge seul, hormis l'étiquette) ; ajoutez tout de même
@media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } .highlight-word { transition: none } .scroll-container { scroll-behavior: auto } }— le défilement doux est lui aussi une animation. - Contraste : les mots éteints sont du texte à lire, pas un décor —
rgba(255,255,255,.15)sur#0a0a0fmesure 1,48:1, illisible tant que le mot n'est pas atteint (et rien n'est allumé avant le premier scroll). Allumé, 19,75:1 ; accent#a855f7, 4,99:1 — conforme à 15,2 px. Passez l'état éteint à.45(4,5:1) si le texte doit pouvoir se lire en avance, ou assumez le masquage et donnez le contenu ailleurs. Sur fond clair, le code ne tient pas : 1,0:1 éteint, 3,96:1 pour l'accent. L'étiquette « Scrollez pour explorer » culmine à 2,6:1 et pulse entre 1,2 et 1,8:1 : c'est une consigne, rendez-la lisible ou retirez-la. - Clavier et tactile : mesuré dans Chromium, Tab donne le focus au bloc (défileurs focalisables au clavier) et chaque flèche bas déplace de 40 px, soit 11 à 12 mots. Firefox et Safari ne focalisent pas un bloc défilant sans
tabindex="0"sur#highlight-scroll— ajoutez-le, avec un:focus-visiblevisible. Molette et défilement tactile imbriqué fonctionnent. La barre de 4 px stylée par::-webkit-scrollbarreste native sur Firefox (scrollbar-width: thin). - Lecteurs d'écran et sans JavaScript : 64
spanen ligne séparés par des espaces dans undiv— le texte est lu comme une phrase, l'état allumé (classes seules) n'est pas annoncé, ce qui convient à un surlignage décoratif ; mettez le texte dans un<p>et restituez les accents. Sans script, ou tant que personne ne défile, tout le texte reste à 15 % : prévoyez un<noscript>qui force la couleur pleine, ou déclenchez un premier calcul au chargement (highlightScroll.dispatchEvent(new Event('scroll'))).
Compatibilité navigateur
Le script tient en ES2015 (const, fonctions fléchées, NodeList.forEach, classList.remove(a, b)) et le CSS repose sur une seule propriété récente : la forme à deux valeurs overflow: hidden auto — un navigateur qui ne la comprend pas ignore la déclaration, le bloc ne défile plus et l'effet est mort. Pas d'animation-timeline ni de scroll-timeline : le surlignage vient d'un écouteur scroll classique, il fonctionne donc sur Firefox, qui ne supporte les animations pilotées au scroll en CSS dans aucune version. Zéro dépendance.
Sans JavaScript, le bloc défile mais tout le texte reste à 15 % d'opacité : aucun mot ne s'allume, seule l'étiquette pulse. Sans overflow: hidden auto (Chrome < 68, Firefox < 61, Safari < 12.1), le bloc s'étire à la hauteur de son contenu et rien ne défile.
Le code
Structure HTML à coller dans votre page (CSS + JS disponibles avec un compte premium) :
<div class="scroll-container" id="highlight-scroll">
<div class="highlight-container" id="highlight-text">
<span class="highlight-word">…</span>
</div>
<div style="height: 300px"></div>
</div>
<span class="scroll-hint-label">…</span>
HTML + CSS + JS complet, prêt à coller — avec des centaines d'effets premium.
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Hauteur du bloc (CSS — .scroll-container { height: 280px }) |
280 px | Fenêtre de lecture. Plus le bloc est haut par rapport au texte, plus le mot allumé reste visible : à 280 px pour 200 px de texte, il sort du cadre dès 47 px de défilement. Une section plein écran (100vh) avec un texte de deux ou trois écrans est le cas favorable. |
Espaceur de fin (HTML — <div style="height: 300px">) |
300 px | Allonge la course après le texte : les derniers mots s'allument quand le texte est déjà sorti (52 px de course sans texte à 784 px de large). Réglez-le à hauteur du bloc − padding − une ligne (226 px ici) pour que la dernière ligne s'allume encore dans le cadre, ou montez le padding-top et posez espaceur = bloc − paddings : la course égale alors la hauteur du texte et le mot courant se fixe à la hauteur du padding. |
Placement du mot courant (JS — Math.floor(t * i)) |
proportionnel à la course | Pour attacher l'allumage à la position réelle des mots, remplacez le calcul par un test géométrique : lit si w.offsetTop − scrollTop < clientHeight × .5, accent au premier mot qui échoue. Le mot courant reste alors à mi-hauteur quelle que soit la largeur, et l'espaceur ne sert plus qu'à atteindre la dernière ligne. |
Couleur éteinte (CSS — .highlight-word { color: rgba(255,255,255,.15) }) |
blanc à 15 % (1,48:1) | Lisibilité du texte non encore atteint. .3 = 2,6:1 (on devine), .45 = 4,5:1 (on lit). Sur fond clair, remplacez par un gris (#9ca3af) — le blanc translucide y disparaît. |
Couleurs allumé et accent (CSS — .lit { color: #fff }, .accent { color: #a855f7 }) |
#fff / #a855f7 | L'accent marque le seul mot courant ; #a855f7 tient 4,99:1 sur #0a0a0f, #c084fc 7,5:1, un ambre #fbbf24 11,8:1 fait surligneur. Un accent peut aussi porter un fond : la transition ne couvre que color, ajoutez background-color. |
Fondu (CSS — transition: color .3s) |
300 ms | Durée du passage éteint → allumé. Un cran de molette bascule 11 à 12 mots d'un coup : à .3s ils se fondent en vague, à 0 ils claquent, à .8s la vague traîne derrière le défilement. |
Corps et interligne (CSS — font-size: .95rem, line-height: 2) |
15,2 px / 30,4 px | Fixent le nombre de lignes, donc la hauteur du texte et la densité de mots par pixel de course (3,44 px par mot à 784 px de large, 5,8 px à 390 px). En grands caractères (26 px, interligne 1,5), 29 mots sur 195 px donnent 6,7 px par mot : lecture cérémonielle. |
Défilement doux et étiquette (CSS — scroll-behavior: smooth, .scroll-hint-label) |
smooth / « Scrollez pour explorer » pulsant 2 s | Le smooth anime aussi les scrollTop posés par script (relu juste après : l'ancienne valeur) : passez-le à auto si vous pilotez la position. L'étiquette est un reste de catalogue : traduisez-la ou retirez le span et l'animation pulseHint. |
FAQ
Math.floor(scrollTop / course × 64) : il avance avec la course, pas avec la position des lignes. Avec l'espaceur de 300 px et un bloc de 280 px, la course (220 px à 784 px de large) est plus longue que le texte (200 px) et démarre le premier mot en haut du cadre : à chaque pixel défilé, le texte monte d'un pixel alors que le mot courant n'avance que de 0,29 mot — il est rattrapé par le bord dès 47 px, plus jamais visible après 107 px, et dès 80 px on ne voit plus que des mots éteints. Deux remèdes : caler la course sur la hauteur du texte (padding-top égal à la hauteur de lecture voulue, espaceur = bloc − paddings), ou remplacer le calcul par un test géométrique par mot (offsetTop − scrollTop < clientHeight × .5). Les trois scènes de cette page utilisent le premier remède : le mot courant y reste visible sur toute la course, entre 53 et 129 px du haut dans les deux premières, entre 106 et 156 px dans la troisième.inline-block, une barre de progression #ssHighlightFill) et les trois états CSS, avec un champ JavaScript vide : rien n'y ajoute lit ni ne remplit la barre. fx-0579 livre l'écouteur de 12 lignes qui fait l'effet, avec des mots en inline (la césure suit le texte, pas le mot) et un fondu de .3 s au lieu de .2 s. Ni l'un ni l'autre ne repose sur animation-timeline ; le surlignage de fx-0579 fonctionne donc sur Firefox, qui n'implémente pas les animations CSS pilotées au scroll.highlightScroll et highlightWords en const au niveau global : collé une seconde fois, il s'arrête sur SyntaxError: Identifier 'highlightScroll' has already been declared. Et même enveloppé dans une fonction, il vise getElementById("highlight-scroll") — un seul élément — et compte tous les .highlight-word de la page : mesuré avec deux blocs, le premier est entièrement allumé à mi-course (64 mots sur 128 comptés) et le second ne réagit jamais. Pour plusieurs instances, enveloppez le code dans document.querySelectorAll('.scroll-container').forEach(c => { const words = c.querySelectorAll('.highlight-word'); … }) : un écouteur par bloc, chacun ne comptant que ses mots — c'est ainsi que les scènes 2 et 3 de cette page sont instanciées.