Construire une table des matières à droite : sticky, scroll-margin, surlignage de la section active
Votre page de documentation est longue. L’en-tête est sticky. Quelqu’un clique une entrée du TOC et arrive sous le titre, à moitié caché derrière la barre de navigation.
Ensuite, le surlignage actif indique une position erronée. Ce n’est pas « un petit bug UI » ; c’est une friction qui fait fuir les lecteurs.
Voici comment construire une table des matières à droite qui se comporte comme si elle était prête pour la production : sticky sans être pénible, des sauts d’ancre qui atterrissent correctement,
et un surlignage de la section active qui n’affole pas le fil principal ni n’induira vos lecteurs en erreur.
La plupart des échecs de TOC sont auto-infligés : mauvais IDs de titres, offsets manquants, et écouteurs de scroll fragiles.
Table des matières
Le TOC que vous voyez à droite est construit à partir des titres de cette page. S’il semble ennuyeusement fiable, tant mieux. C’est l’objectif.
- Exigences qui comptent vraiment
- Faits et contexte : pourquoi les TOC deviennent bizarres
- Mise en page : TOC sticky à droite qui ne se bat pas avec la page
- Navigation par ancres : scroll-margin et gestion des offsets
- Surlignage de la section active : IntersectionObserver bien utilisé
- Performance et fiabilité : éviter les pannes auto-infligées
- Playbook de diagnostic rapide
- Erreurs courantes (symptôme → cause → correction)
- Trois mini-récits d’entreprise depuis le terrain
- Tâches pratiques (commandes, sorties, décisions)
- Checklists / plan pas à pas
- FAQ
- Conclusion : prochaines étapes à livrer
Exigences qui comptent vraiment
Un TOC est un système de navigation. Traitez-le comme tel. Cela signifie qu’il a des exigences au-delà de « ça a l’air bien sur mon écran ».
En pratique, vous optimisez pour : la correction, la réactivité perçue, l’accessibilité et la faible maintenance.
Exigences de correction
- Les sauts d’ancre atterrissent sur la ligne visuelle correcte, pas derrière des en-têtes sticky. Utilisez
scroll-margin-topsur les titres ; cessez de jouer au yoyo avec des offsets JS. - Le surlignage de la section active correspond à ce que voient les utilisateurs. « Le titre le plus proche du haut » est plus subtil qu’il n’y paraît sur de longues pages mêlant H2/H3 et blocs de code.
- Les liens profonds survivent aux refactorings. Les IDs de titres doivent être stables. Si votre générateur change les IDs à cause d’une ponctuation ou d’un emoji, vous casserez des liens dans des tickets, des chats et la mémoire musculaire.
Exigences de performance
- Pas de boucles chaudes sur l’événement scroll. Votre TOC ne doit pas transformer la page en radiateur. Utilisez
IntersectionObserveret limitez les quelques événements restants. - Fonctionne sur de grandes pages. Les pages de docs peuvent atteindre des milliers de lignes. Gérer 200 titres ne devrait pas déclencher une crise.
- Pas de thrash de layout. Évitez d’appeler
getBoundingClientRect()à répétition dans des handlers de scroll.
Exigences UX et accessibilité
- La navigation au clavier fonctionne (Tab pour le TOC, Entrée pour activer). Ne construisez pas un faux menu qui piège le focus.
- États de focus visibles. Si votre TOC met en surbrillance « actif » mais cache le « focus », les utilisateurs clavier souffrent.
- Comportement responsive honnête. Sur mobile, un TOC à droite devient un tiroir en haut ou un composant repliable ; ne forcez pas une mise en page deux colonnes qui écrase le contenu.
Blague n°1 : Un « scrollspy » est comme un stagiaire avec des jumelles — impressionnant jusqu’à ce qu’on réalise qu’il rapporte tout avec cinq secondes de retard.
Faits et contexte : pourquoi les TOC deviennent bizarres
Les TOC paraissent simples parce qu’on en a toujours eu. Mais le web a continué d’évoluer : en-têtes sticky, SPA, contenu dynamique, et primitives de mise en page qui n’existaient pas il y a 15 ans.
Voici des éléments de contexte concrets qui expliquent les angles morts d’aujourd’hui.
position: sticky a été standardisé après des années de comportements fournisseurs ; « sticky dans un conteneur défilant » pose encore problème quand un ancêtre a overflow défini.IntersectionObserver est arrivée précisément pour éviter les handlers de scroll qui forcent des mises en page synchrones et vident la batterie sur mobile.scroll-margin-top fait partie de l’ère CSS Scroll Snap, mais il est largement utile même si vous n’utilisez pas le scroll snapping.General Gordon R. Sullivan
Rien de tout cela n’est exotique. L’astuce est de choisir des primitives stables face au changement : CSS pour les offsets, API d’observation pour le suivi actif, et balisage propre pour l’accessibilité.
Mise en page : TOC sticky à droite qui ne se bat pas avec la page
Le TOC à droite fonctionne mieux quand c’est une colonne sœur dans une grille ou un layout flex, pas un overlay positionné absolument.
Les TOC en overlay sont la raison pour laquelle vous obtenez des bugs « pourquoi je ne peux pas sélectionner du texte » et des comportements de clic étranges.
La mise en page minimale viable
Utilisez une grille avec colonnes pour le contenu et le TOC. Donnez au TOC position: sticky et un offset top judicieux qui tient compte de votre en-tête sticky.
Puis faites défiler le TOC en interne avec max-height et overflow: auto pour qu’il ne dépasse pas la fenêtre.
Mode d’échec : sticky ne colle pas. Presque toujours causé par un ancêtre avec overflow: hidden/auto ou un contexte de hauteur manquant.
Sticky est pointilleux, pas cassé.
Règles de conteneur qui vous évitent des ennuis
- N’emballez pas toute la page dans un conteneur défilant sauf si vous avez une vraie raison. Laissez
document.scrollingElementêtre le comportement par défaut du navigateur. - Si vous devez avoir un conteneur de scroll, faites observer explicitement ce conteneur par le TOC (observer
root), sinon votre état « actif » va traîner ou se casser. - Gardez une largeur de TOC relativement fixe, mais évitez les pixels codés en dur partout. Une largeur basée sur clamp ou une variable CSS rend les ajustements futurs peu coûteux.
Rendez-le responsive sans exploits
Sur les écrans plus petits, la « droite » n’existe plus. Vous avez deux options raisonnables :
- Déplacer le TOC au-dessus de l’article (simple, fiable, sans JS).
- Tiroir repliable (plus complexe ; vous devez alors gérer le focus et les états ARIA).
Si vous choisissez le tiroir, traitez-le comme un composant avec des tests. Sinon, il régressera la prochaine fois que quelqu’un « ajuste juste les paddings ».
Navigation par ancres : scroll-margin et gestion des offsets
Voici le bug le plus courant du TOC : on clique un lien, le navigateur scroll, et l’en-tête sticky couvre le titre.
Les gens remontent ensuite légèrement, ce qui change la section « active », et le surlignage du TOC saute. Ce n’est pas un petit glitch ; c’est une boucle.
Utilisez scroll-margin-top sur les titres (pas des offsets JS)
Mettez ceci sur les titres eux-mêmes :
h2, h3 { scroll-margin-top: calc(var(--headerHeight) + 16px); }
Cela fonctionne pour la navigation par hash, element.scrollIntoView() programmatique et les sauts initiés par l’utilisateur.
Ça s’étend aussi aux pages sans avoir à se souvenir « ajouter 72px dans six fonctions différentes ».
Stabilisez les IDs des titres
Vos liens TOC ne sont fiables que si vos IDs le sont. En production, les gens collent des liens profonds dans des tickets.
Puis vous renommez « Cache & Consistency » en « Cache consistency », votre générateur change le slug, et soudain votre équipe de support pratique l’archéologie.
Faites ceci :
- Privilégiez des IDs explicitement définis quand possible (l’auteur les écrit, le générateur les respecte).
- Utilisez une fonction de slug déterministe (minuscules, tirets, enlever la ponctuation) et verrouillez-la comme contrat d’interface.
- Quand vous devez changer des IDs, fournissez des redirections pour les hashes si votre plateforme le permet (certains routeurs statiques peuvent mapper des hashes hérités).
Décidez du comportement du focus
La navigation par hash scroll, mais elle peut ne pas déplacer le focus clavier vers le titre. Pour l’accessibilité, il est souvent pertinent de focusser le titre (ou une ancre cachée),
mais vous devez éviter de casser la position de scroll en appelant focus() sans preventScroll.
Une approche pragmatique :
- Au clic sur le TOC, laissez le navigateur scroller vers le hash.
- Puis appelez
heading.focus({ preventScroll: true })sur un titre focusable (ajouteztabindex="-1").
Si vous passez cela, les utilisateurs clavier et les aides techniques auront une expérience dégradée. Si vous l’implémentez mal, vous obtiendrez des saccades de scroll. Choisissez, puis implémentez soigneusement.
Surlignage de la section active : IntersectionObserver bien utilisé
Le surlignage actif n’est pas une fonctionnalité gadget. C’est ce qui permet au lecteur de garder son orientation sur de longues pages.
Quand c’est faux, les lecteurs le ressentent immédiatement. Ils ne vont pas forcément ouvrir un ticket, mais ils cesseront de faire confiance à votre doc.
Le modèle : « actif » signifie le titre le plus proche au-dessus du seuil
Le modèle naïf est « le titre actuellement visible ». Cela casse quand deux titres sont visibles, ou quand un titre est visible mais que vous êtes profondément dans la section.
Une meilleure définition :
- Définissez une ligne seuil en haut (généralement juste sous l’en-tête sticky).
- La section active est le dernier titre dont le haut est au-dessus de cette ligne.
IntersectionObserver peut l’approcher en observant les titres et en suivant ceux qui ont franchi une « bande haute ».
Vous réglez cela via rootMargin pour que le « viewport » de l’observer commence sous l’en-tête sticky.
Une stratégie d’observer robuste
Utilisez un observer pour les titres (H2 et éventuellement H3). Maintenez une petite map des états de visibilité.
Sur callback, calculez le meilleur titre actif basé sur :
- L’ordre des titres dans le document
- Si la boîte englobante du titre se trouve dans une bande haute
- Fallback vers le premier titre en haut de page, ou le dernier titre près du bas
Ne pas : à chaque scroll, parcourir tous les titres et appeler getBoundingClientRect(). Ce pattern provoque du thrash de layout et des temps de trame inconsistants sur de longues pages.
Gérer le « jump to hash » et le bouton précédent
Quand la page charge avec un hash, le navigateur scrolle avant que votre JS ne soit prêt. Le surlignage du TOC doit quand même être correct.
Cela signifie :
- À l’initialisation, définissez l’actif basé sur
location.hashsi ça correspond à un ID de titre. - Après stabilisation des polices/images, réévaluez une fois (pas en permanence). Un seul
requestAnimationFrameousetTimeoutaprès le load suffit souvent.
Gardez l’entrée TOC active visible dans le TOC
Si le TOC défile lui-même, vous devez garantir que l’élément actif reste visible (auto-scroll discret).
Mais faites-le poliment : ne scrollez le TOC que lorsque l’élément actif est hors vue, et utilisez scrollIntoView({ block: "nearest" }).
Blague n°2 : Si votre surlignage de TOC traîne, félicitations — vous avez inventé une page d’état pour le scrolling.
Performance et fiabilité : éviter les pannes auto-infligées
Un TOC de docs peut impacter de réels résultats business. Pas par la consommation CPU, mais par la perte de confiance.
Quand la navigation casse, les gens supposent que le contenu est aussi bâclé. Et quand vos docs vivent dans une UI produit, un mauvais TOC peut dégrader la performance globale de l’app.
Où les TOC meurent
- Injection de contenu dynamique : les titres apparaissent après le rendu initial (hydratation MDX, fetch côté client). Votre TOC doit rescanner et ré-observer.
- Déplacements de layout : images sans dimensions, polices web qui chargent tard, accordéons qui s’ouvrent. Votre logique « titre actif » doit tolérer le mouvement.
- Conteneurs de scroll imbriqués : le contenu principal défile dans un div, pas la fenêtre. Les observers ont besoin du
rootapproprié, etscroll-margin-toppeut ne pas correspondre au comportement visible de l’en-tête. - Trop d’observers : créer un observer par titre est coûteux. Vous voulez une instance d’observer unique surveillant de nombreuses cibles.
Pièges d’accessibilité
Un TOC est essentiellement un ensemble de liens intra-page. Traitez-le comme un <nav aria-label="On this page">.
Restez simple et sémantique. La sur-ingénierie est là où ARIA se perd.
- Utilisez
aria-current="true"(ouaria-current="location") sur le lien actif. - Gardez le texte des liens court et identique aux titres quand possible (les lecteurs d’écran ne doivent pas deviner).
- Assurez-vous que les styles de focus sont visibles sur votre fond.
Réalités SPA et plateformes de docs
Si vous avez un routage côté client, vous devez reconstruire le TOC au changement de route et rattacher les observers. Cela signifie :
- Écoutez les événements de route (spécifiques au framework) et relancez l’initialisation du TOC.
- Déconnectez les observers au teardown pour éviter les fuites mémoire.
- Ne supposez pas que les titres existent immédiatement après la navigation ; attendez la fin du rendu.
Perspective SRE : si une fonctionnalité UI nécessite une séquence d’init complexe, elle cassera lors de déploiements partiels, tests A/B, ou expériences de contenu.
Rendez le TOC tolérant aux titres manquants et au contenu retardé.
Playbook de diagnostic rapide
Quand le TOC casse en production, vous n’avez pas le temps pour des débats philosophiques sur les APIs de scroll.
Il vous faut un entonnoir rapide qui trouve le goulot : layout CSS, offsets d’ancre, logique d’observer, ou cycle de vie plateforme.
Premier point : le sticky fonctionne-t-il ?
- Ouvrez la page, scrollez, confirmez que le TOC reste épinglé sous l’en-tête.
- Si ce n’est pas le cas : inspectez les ancêtres pour
overflowet les transforms. Sticky échoue silencieusement quand le contexte de layout est mauvais.
Second point : les sauts d’ancre atterrissent-ils correctement ?
- Cliquez un élément TOC en milieu de page. Si le titre est caché sous l’en-tête, il vous manque
scroll-margin-top(ou vous l’avez appliqué au mauvais élément). - Si ça atterrit correctement mais « saute » ensuite, vous avez probablement un JS concurrent qui appelle
scrollIntoView()ou focus sanspreventScroll.
Troisième point : le surlignage actif est-il correct et stable ?
- Défiler lentement au niveau d’une frontière entre sections. Si le surlignage clignote, vos seuils observer ou rootMargin sont mauvais.
- Si ça ne se met jamais à jour, l’observer peut surveiller le mauvais root (fenêtre vs conteneur de scroll) ou les titres ne sont pas observés après hydratation.
Quatrième point : casse-t-il seulement sur certaines pages ?
- Comparez des pages avec différents types de contenu : beaucoup de blocs de code, images, titres imbriqués, ou panneaux repliables.
- Cherchez des collisions d’ID de titre (titres dupliqués) et des IDs invalides (espaces, ponctuation) si votre générateur est négligent.
Cinquième point : casse-t-il seulement après une navigation SPA ?
- Si le chargement initial fonctionne mais pas les routes suivantes, vous ne réinitialisez pas ou vous ne déconnectez pas les anciens observers.
- Si ça marche après un rafraîchissement complet mais pas après navigation client, votre code s’exécute avant que les titres ne soient rendus.
Erreurs courantes (symptôme → cause → correction)
Symptôme : cliquer un lien TOC amène « trop haut » ou « trop bas »
Cause : scroll-margin-top manquant, ou appliqué au mauvais élément (comme un div wrapper au lieu du titre). Parfois la hauteur de l’en-tête sticky change avec les breakpoints.
Correction : appliquez scroll-margin-top directement aux h2/h3 (ou à l’élément ancré) en utilisant une variable CSS pour la hauteur d’en-tête par breakpoint.
Symptôme : le surlignage TOC est incorrect près du bas de la page
Cause : le dernier titre n’intersecte jamais la bande de l’observer, surtout si rootMargin/threshold est réglé pour le milieu de page.
Correction : ajoutez un sentinel bas, ou gérez le cas « près du bas » en vérifiant la position de scroll vs scrollHeight, puis forcez le dernier titre comme actif.
Symptôme : le surlignage clignote rapidement entre deux titres
Cause : seuils trop sensibles ; titres proches ; déplacements de layout (images) provoquant de petits scrolls. Un autre coupable est de mélanger H2 et H3 sans règle cohérente d’« actif ».
Correction : utilisez une stratégie de seuil unique (bande haute), réduisez les cibles observées (ne suivez que les H2 pour l’actif, marquez les H3 comme secondaires), et débouchez les mises à jour sur animation frames.
Symptôme : le TOC sticky cesse de coller sur certaines pages
Cause : un ancêtre a overflow: hidden/auto, ou un transform défini, créant un nouveau bloc contenant qui change le comportement sticky.
Correction : retirez l’overflow/transform de l’ancêtre, ou déplacez l’élément sticky en dehors de ce contexte. Si vous devez le garder, utilisez une autre stratégie de layout (par ex. fixed + padding) mais attendez-vous à plus de travail.
Symptôme : les entrées du TOC ne correspondent plus aux titres après mise à jour du contenu
Cause : TOC généré à la compilation, mais contenu injecté au runtime ; ou les titres sont modifiés via rendu côté client après l’initialisation du TOC.
Correction : générez le TOC au runtime après le rendu, ou observez les mutations du DOM (avec parcimonie) et rescannez les titres sur changements significatifs.
Symptôme : cliquer le TOC provoque un « double scroll » saccadé
Cause : à la fois la navigation hash par défaut et un handler JS appellent scrollIntoView(). Ou vous focussez le titre sans preventScroll.
Correction : choisissez une seule méthode de navigation. Si vous utilisez les hashes, laissez le navigateur scroller et n’ajoutez que le focus avec preventScroll.
Symptôme : la page semble lente seulement lors du scroll
Cause : handler de scroll effectuant des lectures/écritures de layout répétées. Ou trop de mises à jour DOM (toggle de classes) par tick de scroll.
Correction : migrez vers IntersectionObserver, mettez à jour le DOM uniquement lorsque le titre actif change, et regroupez les changements de classes.
Symptôme : titres dupliqués cassent les liens profonds
Cause : votre générateur de slugs émet le même ID pour des titres identiques, les liens pointent donc vers la première occurrence.
Correction : désambiguïsez les IDs en ajoutant un suffixe compteur de façon déterministe (par ex. -2, -3).
Trois mini-récits d’entreprise depuis le terrain
Incident : la mauvaise hypothèse sur « le conteneur de scroll »
Une entreprise a livré un portail de documentation embarqué dans leur UI produit. Ça avait l’air moderne : nav fixe en haut, rail gauche, et un joli TOC à droite.
Mais il y avait aussi un conteneur de scroll personnalisé parce que l’équipe produit voulait que toute la frame de l’app donne une sensation « native ».
Le surlignage TOC fonctionnait parfaitement en staging. En production, c’était du non-sens : parfois l’élément actif ne changeait jamais ; parfois il sautait de deux sections.
Le support l’a signalé comme « sporadique » car cela dépendait de la hauteur de viewport, le type de bug qui aime être mal diagnostiqué.
L’hypothèse erronée était subtile : l’implémentation du TOC utilisait IntersectionObserver avec la root par défaut (le viewport).
Mais le scroll réel se produisait à l’intérieur de div.app-scroll. Du point de vue du navigateur, les titres ne bougeaient pas par rapport au viewport comme l’équipe l’attendait.
La correction fut ennuyeuse et immédiate : définir le root de l’observer sur le conteneur de scroll, et utiliser un rootMargin qui tient compte de l’en-tête sticky dans ce même conteneur.
Ils ont aussi supprimé une couche d’overflow imbriquée qui empêchait le sticky du TOC lui-même.
Enseignement : si vous créez un scroll personnalisé, vous assumez tous les effets secondaires. « Conteneur de scroll » n’est pas un détail ; c’est le personnage principal.
Optimisation contre-productive : « pré-calculer tout au scroll »
Une autre organisation avait un site de docs avec beaucoup de contenu technique — nombreux blocs de code et références API.
Ils ont trouvé que le surlignage TOC était lent sur des machines modestes, alors quelqu’un a « optimisé » en mettant en cache la position Y de chaque titre au chargement de la page.
Ce changement a bien passé des tests sur un petit ensemble de pages. Puis il a été déployé à l’ensemble du corpus.
Une semaine plus tard, les rapports arrivaient : cliquer un lien TOC atterrit correctement, mais le surlignage est décalé d’une section. Pire sur les pages avec diagrammes.
Voilà ce qui s’est passé : images et polices ont chargé après le cache initial. Le layout a bougé, les titres ont changé de position, mais les positions Y en cache ne l’ont pas fait.
La logique est restée rapide, certes. Elle était aussi fausse avec assurance — probablement la pire forme d’erreur.
Ils ont reverté le caching et migré vers IntersectionObserver. Là où ils avaient encore besoin d’offsets (bouton spécial « scroll vers la section suivante »), ils calculaient les positions paresseusement, à la demande, et n’ont jamais supposé que le layout était stable avant le chargement complet.
Enseignement : une optimisation qui ignore les déplacements de layout n’est pas une optimisation ; c’est un voyage dans le temps vers un DOM passé qui n’existe plus.
Pratique ennuyeuse mais correcte qui a sauvé la journée : IDs stables et couche de compatibilité
Une équipe a migré d’un moteur Markdown à un autre pour un meilleur highlighting.
Nouveau renderer, nouvelles règles de slug. Soudain, d’anciens liens profonds issus de tickets et de runbooks ont commencé à échouer.
Pas une panne catastrophique, mais ça a affecté ceux déjà stressés : ingénieurs on-call cherchant des procédures.
L’équipe qui a évité la douleur avait fait deux choses ennuyeuses plus tôt :
d’abord, exiger des IDs explicites pour les titres de niveau supérieur ; ensuite, garder une petite map de compatibilité pour les hashes hérités redirigeant vers les nouveaux IDs.
Pendant la migration, ils ont exécuté une vérification automatisée sur le corpus : extraire chaque ID de titre avant/après, les différencier, et générer des mappings pour les casse-têtes courants.
Là où ils ne pouvaient pas mapper de façon fiable, ils ont refusé le changement jusqu’à ce que les auteurs fournissent des IDs explicites.
Le résultat fut terne. Pas de liens cassés surprises. Le support n’a rien remarqué. L’on-call n’a rien remarqué. C’est la victoire.
Enseignement : les ancres stables sont des données opérationnelles. Traitez-les avec la même rigueur que la compatibilité d’API.
Tâches pratiques (commandes, sorties, décisions)
Ci-dessous des tâches à lancer aujourd’hui sur une machine Linux typique ou un runner CI pour diagnostiquer et prévenir des régressions TOC.
Chaque tâche inclut : une commande, ce que signifie la sortie, et la décision associée.
J’utilise un répertoire de sortie statique nommé dist/ et un répertoire source docs/. Adaptez les noms ; gardez l’intention.
Tâche 1 : Confirmer que les titres ont des IDs (hygiène de base)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23][^>]*>' dist | head
dist/guide.html:118:<h2 id="requirements-that-actually-matter">Requirements that actually matter</h2>
dist/guide.html:176:<h2 id="facts-and-context">Facts and context: why TOCs got weird</h2>
dist/guide.html:265:<h3 id="the-minimum-viable-layout">The minimum viable layout</h3>
Ce que ça signifie : vous voyez des titres avec id="...". Si les IDs manquent, vos liens TOC seront fragiles ou impossibles.
Décision : si les IDs sont absents, corrigez votre pipeline de rendu/build pour émettre des IDs déterministes ou exigez des IDs explicites dans l’écriture.
Tâche 2 : Détecter les titres sans IDs (liste de défaillance)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23](?![^>]*\sid=)[^>]*>' dist | head
dist/faq.html:44:<h2>FAQ</h2>
dist/intro.html:90:<h3 class="note">A subtle caveat</h3>
Ce que ça signifie : ces titres n’ont pas d’IDs.
Décision : soit (a) générez des IDs à la compilation, soit (b) excluez ces titres du générateur TOC pour éviter les liens morts.
Tâche 3 : Trouver les IDs dupliqués (corruption silencieuse des liens)
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import Counter
ids = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids += re.findall(r'\bid="([^"]+)"', s)
c = Counter(ids)
dups = [k for k,v in c.items() if v > 1]
print("duplicate ids:", len(dups))
print("\n".join(dups[:20]))
PY
duplicate ids: 2
faq
overview
Ce que ça signifie : au moins deux IDs sont répétés entre pages (ou dans une page). Le vrai problème est la duplication dans une même page pour la navigation par hash.
Décision : assurez-vous que les IDs sont uniques par page. Si les duplications sont inter-pages seulement, ça va. Si c’est intra-page, modifiez votre slugger pour ajouter des suffixes.
Tâche 4 : Vérifier que scroll-margin-top existe dans le CSS compilé
cr0x@server:~$ rg -n --glob 'dist/**/*.css' 'scroll-margin-top' dist | head
dist/assets/site.css:211:h2{scroll-margin-top:calc(var(--headerHeight) + 16px)}
dist/assets/site.css:212:h3{scroll-margin-top:calc(var(--headerHeight) + 16px)}
Ce que ça signifie : votre build inclut la règle d’offset.
Décision : si elle manque, ajoutez-la dans la feuille de style globale du contenu des docs, pas sur une page isolée.
Tâche 5 : Vérifier si le sticky est battu par un overflow sur les ancêtres
cr0x@server:~$ rg -n --glob 'dist/**/*.html' 'overflow:\s*(auto|hidden|scroll)' dist | head
dist/assets/site.css:88:.shell{overflow:hidden}
dist/assets/site.css:132:.content-wrap{overflow:auto}
Ce que ça signifie : vous avez des règles overflow qui peuvent créer des contextes de contention pour sticky.
Décision : auditez les wrappers de layout. Si le TOC est à l’intérieur d’un élément avec overflow autre que visible, le comportement sticky peut changer. Déplacez l’élément sticky ou retirez le wrapper overflow.
Tâche 6 : Confirmer que vos liens TOC correspondent à de vrais IDs
cr0x@server:~$ python3 - <<'PY'
import bs4, glob
from bs4 import BeautifulSoup
for fn in ["dist/guide.html"]:
s = open(fn, "r", encoding="utf-8").read()
soup = BeautifulSoup(s, "html.parser")
ids = {t.get("id") for t in soup.select("[id]")}
bad = []
for a in soup.select("aside#toc a[href^='#']"):
h = a.get("href")[1:]
if h and h not in ids:
bad.append(h)
print(fn, "bad toc hrefs:", bad[:20])
PY
dist/guide.html bad toc hrefs: []
Ce que ça signifie : les ancres du TOC résolvent des éléments sur la page.
Décision : s’il y a des hrefs cassés, votre générateur TOC n’est pas synchronisé avec le renderer, ou des titres sont ajoutés/supprimés après la génération du TOC.
Tâche 7 : Détecter les « hash links » qui ne pointent nulle part sur le site
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import defaultdict
by_page_ids = {}
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids = set(re.findall(r'\bid="([^"]+)"', s))
by_page_ids[fn] = ids
broken = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
for href in re.findall(r'href="#([^"]+)"', s):
if href not in by_page_ids[fn]:
broken.append((fn, href))
print("broken in-page hashes:", len(broken))
for x in broken[:10]:
print(x[0], "#"+x[1])
PY
broken in-page hashes: 1
dist/faq.html #top
Ce que ça signifie : au moins un lien hash intra-page ne correspond à aucun ID sur cette page.
Décision : ajoutez la cible manquante (par ex. id="top") ou retirez le lien.
Tâche 8 : Mesurer combien de titres vous observez (vérification d’échelle)
cr0x@server:~$ python3 - <<'PY'
import glob, re
fn = "dist/guide.html"
s = open(fn, "r", encoding="utf-8").read()
h2 = len(re.findall(r'<h2\b', s))
h3 = len(re.findall(r'<h3\b', s))
print("h2:", h2, "h3:", h3, "total:", h2+h3)
PY
h2: 12 h3: 27 total: 39
Ce que ça signifie : cette page a 39 titres. Observer 39 éléments va bien. Observer 400 peut encore aller, mais il faut être délibéré.
Décision : si le nombre de titres est énorme, envisagez d’observer seulement les H2 pour l’actif, et traiter les H3 comme navigation seulement (sans suivi actif), ou implémentez une sélection plus intelligente.
Tâche 9 : Confirmer que vous n’expédiez pas un handler de scroll qui tourne chaque tick
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'addEventListener\(\s*["'\'']scroll["'\'']' dist | head
dist/assets/toc.js:14:window.addEventListener('scroll', onScroll)
Ce que ça signifie : vous attachez un écouteur scroll.
Décision : inspectez ce qu’il fait. S’il lit le layout et met à jour le DOM à chaque événement scroll, remplacez par IntersectionObserver ou throttlez vers les animation frames et mettez à jour seulement quand l’actif change.
Tâche 10 : Confirmer qu’IntersectionObserver est utilisé (et pas par titre)
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'new\s+IntersectionObserver' dist
dist/assets/toc.js:38:const io = new IntersectionObserver(onIntersect, { root: null, rootMargin: '-72px 0px -70% 0px', threshold: [0, 1] })
Ce que ça signifie : le build inclut une instance d’IntersectionObserver.
Décision : confirmez qu’il s’agit d’un seul observer réutilisé pour de nombreux titres. Si vous le voyez créé dans une boucle, corrigez cela.
Tâche 11 : Vérifier localement la performance style Lighthouse/PSI (rapide)
cr0x@server:~$ node -e "console.log('Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.');"
Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.
Ce que ça signifie : oui, c’est une commande placeholder — mais la décision est réelle : mesurez la performance du scroll avec des outils, pas au feeling.
Décision : si le scroll génère de longs tasks, inspectez d’abord le code du TOC. C’est une cause fréquente car il tourne pendant le scroll par définition.
Tâche 12 : Vérifier que les titres sont dans un seul repère main pour les lecteurs d’écran
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<main\b' dist | head
dist/guide.html:52:<main>
dist/faq.html:18:<main>
Ce que ça signifie : la page utilise un repère <main>, ce qui améliore la navigation pour les technologies d’assistance.
Décision : si absent, ajoutez-le. Ensuite assurez-vous que votre TOC est un <nav aria-label="On this page"> landmark, pas un div aléatoire.
Tâche 13 : Vérifier que l’élément TOC actif définit aria-current
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'aria-current' dist
dist/assets/toc.js:97:link.setAttribute('aria-current', isActive ? 'location' : 'false')
Ce que ça signifie : votre JS bascule aria-current.
Décision : si absent, ajoutez-le. S’il met des valeurs invalides, corrigez pour location ou supprimez l’attribut quand inactif.
Tâche 14 : Détecter les changements de texte de titre qui casseraient les IDs stables
cr0x@server:~$ git diff --word-diff -- docs/guide.md | head -n 40
diff --git a/docs/guide.md b/docs/guide.md
--- a/docs/guide.md
+++ b/docs/guide.md
@@
-## Active section highlighting: IntersectionObserver done right
+## Active section highlighting with IntersectionObserver
Ce que ça signifie : un titre a changé. Si vos IDs sont dérivés du texte du titre, l’ID a probablement changé aussi.
Décision : soit conservez des IDs explicites stables, soit acceptez le changement brisant et gérez-le (redirections/mappings). Ne le laissez pas dériver en silence.
Tâche 15 : Confirmer que les changements de route SPA réinitialisent le TOC (test smoke via logs)
cr0x@server:~$ rg -n --glob 'src/**/*.ts' 'initToc\(|setupToc\(' src | head
src/toc/init.ts:12:export function initToc(){ ... }
src/router.ts:48:router.on('routeChangeComplete', () => initToc())
Ce que ça signifie : l’initialisation du TOC est appelée après le changement de route.
Décision : si absent, ajoutez-la. Si présent mais bogué, assurez-vous qu’il déconnecte les anciens observers et gère le rendu différé.
Checklists / plan pas à pas
Pas à pas : livrer un TOC qui se comporte
- Définissez votre politique de titres. Choisissez quels niveaux apparaissent (H2 seulement, ou H2+H3). Décidez d’IDs stables.
- Implémentez l’offset de scroll en CSS. Ajoutez
scroll-margin-topsur les titres en utilisant une variable de hauteur d’en-tête. - Construisez un balisage sémantique. Le TOC est un
<nav aria-label="On this page">avec une liste de liens. - Rendez le TOC sticky. Utilisez une grille ; appliquez
position: sticky, offset top, max-height, et défilement interne. - Surlignage actif avec IntersectionObserver. Une instance d’observer, de nombreuses cibles, rootMargin ajusté.
- Définissez aria-current. Utilisez
aria-current="location"sur le lien actif ; retirez quand inactif. - Gérez le hash au chargement. À l’init, si
location.hashcorrespond à un titre, marquez-le actif immédiatement. - Gérez la navigation SPA. Rescanner les titres au changement de route et déconnecter les anciens observers.
- Testez les cas de layout shift. Pages avec images, blocs de code et accordéons. Confirmez l’absence de flicker.
- Gardes-fous en CI. Vérifiez les IDs manquants, IDs dupliqués et liens hash cassés intra-page.
Checklist : durcissement en production
- Le TOC fonctionne si JavaScript échoue (les liens scrolleront via les hashes).
- La hauteur de l’en-tête sticky est cohérente (ou la variable CSS change par breakpoint).
- Les IDs sont stables et déterministes ; les duplications sont désambiguïsées.
- Le surlignage actif ne flicke pas lors d’un scroll lent.
- Le TOC ne provoque pas de longs tasks pendant le scroll.
- La navigation clavier et les états de focus sont visibles.
Checklist : ce qu’il ne faut pas faire
- Ne pas attacher un écouteur de scroll qui recalcule toutes les positions de titres à chaque tick.
- Ne pas stocker des offsets mis en cache à moins de suivre aussi les déplacements de layout (ce que vous ne ferez probablement pas correctement).
- Ne pas construire un « TOC tiroir » sur mobile si vous n’êtes pas prêt à gérer ARIA, le focus trap, et l’échappement.
- Ne laissez pas les IDs de titres changer à la légère. C’est un changement cassant ; traitez-le comme tel.
FAQ
1) Dois-je générer le TOC à la compilation ou au runtime ?
La compilation est plus simple et plus rapide, mais seulement si vos titres rendus sont stables et ne sont pas injectés après le chargement.
Si vous avez de l’hydratation MDX ou du contenu côté client, faites la génération au runtime (ou compilation + réconciliation runtime).
2) scroll-margin-top est-il suffisamment supporté pour s’y fier ?
Oui pour les navigateurs modernes. Si vous supportez des navigateurs très anciens, vous aurez besoin d’un fallback, mais la plupart des portails docs peuvent exiger des moteurs modernes.
Le risque majeur n’est pas le support ; c’est d’oublier de l’appliquer à l’élément réellement ancré.
3) Pourquoi ne pas simplement utiliser un handler sur scroll ?
Vous pouvez, mais vous réinventerez mal la moitié d’IntersectionObserver : throttling, lectures de layout, et cas limites autour du bas de page.
Les handlers de scroll tendent aussi à régresser parce que « ça marche » jusqu’à ce qu’on ajoute un nouveau type de contenu.
4) Mon surlignage TOC est décalé d’une section. Quelle est la cause habituelle ?
RootMargin qui n’intègre pas la hauteur de l’en-tête sticky, ou votre définition d’« actif » est « tout titre visible » plutôt que « dernier titre au-dessus d’une ligne seuil ».
Réglez rootMargin et adoptez une règle déterministe.
5) Dois-je surligner aussi les éléments H3 ?
Seulement si cela aide les lecteurs. Sur des pages très denses, surligner les H3 peut clignoter car les H3 sont proches.
Un compromis courant : l’actif est le H2 courant ; à l’intérieur, surlignez le H3 le plus proche seulement s’il est suffisamment espacé.
6) Comment éviter de casser les liens profonds quand les titres changent ?
Utilisez des IDs explicites pour les sections importantes (runbooks, API, dépannage). Si vous ne pouvez pas, maintenez une couche de compatibilité qui mappe les anciens hashes vers les nouveaux IDs.
Sinon, acceptez que vous déployez un changement cassant et communiquez-le.
7) Et pour les pages avec sections repliables ou onglets ?
Les repliables compliquent l’« actif » car la visibilité du contenu change. La voie sûre : n’incluez que les titres toujours présents dans le flux.
Si vous incluez des titres dans des repliables, votre TOC doit comprendre l’état ouvert/fermé et mettre à jour les observers en conséquence.
8) Pourquoi le sticky échoue seulement sur certaines pages ?
Parce que certaines pages ont un wrapper qui définit overflow ou transform et change le bloc contenant sticky.
Sticky n’est pas global ; il est contextuel. Auditez les styles des ancêtres, pas seulement l’élément sticky.
9) Dois-je auto-scroller la barre TOC pour garder l’élément actif visible ?
C’est un plus si votre TOC est long. Faites-le seulement quand nécessaire (block: "nearest"), et ne luttez pas contre l’utilisateur s’il fait défiler manuellement le TOC.
10) Comment tester cela de façon fiable ?
Utilisez des pages déterministes : une avec beaucoup de titres, une avec de grosses images, une avec des blocs de code, une avec des repliables.
Ajoutez des contrôles automatisés pour les liens hash cassés et les IDs dupliqués. Pour le surlignage actif, faites des tests navigateurs qui scrollent à des offsets connus et vérifient aria-current.
Conclusion : prochaines étapes à livrer
Un TOC à droite semble être une fonctionnalité cosmétique jusqu’à ce qu’elle casse et que tout le monde devienne détective UI réticent.
La solution n’est pas plus de JavaScript. C’est choisir les bonnes primitives et appliquer des règles ennuyeuses.
- Ajoutez
scroll-margin-topaux titres en utilisant une variable de hauteur d’en-tête correspondant à votre header sticky. - Assurez des IDs de titres stables et uniques (explicites quand c’est important ; slugging déterministe ailleurs).
- Rendez le TOC sticky via une grille, et évitez les wrappers overflow qui sabotent le sticky.
- Utilisez un seul IntersectionObserver pour piloter le surlignage actif, avec un rootMargin adapté sous l’en-tête.
- Déployez des gardes-fous CI pour les IDs manquants, les IDs dupliqués et les liens hash intra-page cassés.
Si vous ne faites que deux choses : utilisez scroll-margin-top et arrêtez d’exécuter une logique lourde dans un handler de scroll.
Cela élimine à lui seul la plupart des bugs TOC que j’ai vus en production.