Table des matières avec Scroll Spy
Table des matières automatique générée à partir des titres h1-h3 d'une page, avec un scroll spy positionnel qui met en gras la section en cours de lecture et un défilement fluide des ancres.
Table des matières
Génération automatique d’une table des matières à partir des titres de la page, avec mise en évidence de la section active.

Le panneau Sur cette page à droite est la table des matières, construite à partir des titres h1–h3 de la page.
Aperçu
- Génération automatique : extraite des titres h1-h3 (la plage est un paramètre, voir Niveaux de titre)
- Scroll Spy : met en évidence la section courante
- Défilement fluide : navigation animée
- Responsive : barre latérale sur ordinateur, offcanvas sur mobile
Implémentation
Modèle d’inclusion
{% include content/toc.html %}
Génération de la table des matières
L’include toc.html utilise la table des matières intégrée de Kramdown :
<nav id="TableOfContents" class="toc">
<h2 class="toc-title">On This Page</h2>
{{ content | toc_only }}
</nav>
Ou une extraction manuelle :
<nav id="TableOfContents">
<ul class="toc-list">
{% for heading in page.content | split: '<h' %}
{% if heading contains 'id="' %}
{% assign id = heading | split: 'id="' | last | split: '"' | first %}
{% assign level = heading | slice: 0, 1 %}
{% assign text = heading | split: '>' | last | split: '<' | first %}
<li class="toc-item toc-level-{{ level }}">
<a href="#{{ id }}" class="toc-link">{{ text }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
Configuration
Activer la table des matières
Dans le front matter :
---
toc: true
---
Ou pour l’ensemble du site dans _config.yml :
defaults:
- scope:
type: docs
values:
toc: true
Niveaux de titre
La plage est définie à l’endroit où la table des matières est incluse, avec les paramètres h_min / h_max. _includes/navigation/sidebar-right.html affiche le panneau de droite avec h_min=1 h_max=3, de sorte que le titre de la page et ses sections h2/h3 apparaissent :
{% include content/toc.html html=content h_min=1 h_max=3 %}
L’include lui-même utilise par défaut h_min=1 et h_max=6 ; tout ce qui se trouve en dehors de la plage est ignoré, tout comme tout titre dépourvu d’un id.
Mise en forme
Styles de base
.toc {
position: sticky;
top: 80px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.toc-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-link {
display: block;
padding: 0.25rem 0;
color: var(--bs-secondary);
text-decoration: none;
font-size: 0.875rem;
border-left: 2px solid transparent;
padding-left: 0.75rem;
}
.toc-link:hover {
color: var(--bs-primary);
}
.toc-link.active {
color: var(--bs-primary);
border-left-color: var(--bs-primary);
font-weight: 500;
}
Niveaux imbriqués
.toc-level-3 {
padding-left: 1rem;
}
.toc-level-4 {
padding-left: 2rem;
font-size: 0.8125rem;
}
Scroll Spy
assets/js/modules/navigation/scroll-spy.js met en gras l’entrée de la section que vous lisez. La règle est positionnelle plutôt que basée sur la visibilité : le titre actif est le dernier dont le haut a franchi la ligne de lecture — une ligne située à scroll-padding-top sous le haut de la fenêtre, c’est-à-dire juste sous l’en-tête fixe, qui est aussi l’endroit où un clic sur une entrée de la table des matières positionne son titre. Une fois la page défilée jusqu’en bas, le dernier titre l’emporte, de sorte que les sections finales plus courtes que la fenêtre restent accessibles.
// Simplified: the active heading, recomputed on each scroll frame.
function activeHeading(headings) { // headings sorted by document offset
const pad = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop);
const atBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4;
if (atBottom) return headings[headings.length - 1];
const line = window.scrollY + pad + 4;
let active = headings[0];
for (const heading of headings) {
if (heading.top > line) break;
active = heading;
}
return active;
}
Les décalages des titres sont mesurés une fois puis remesurés lors d’un redimensionnement ou d’un remaniement du contenu (ResizeObserver), et le recalcul est limité à une image d’animation par défilement, de sorte que toute la page est réévaluée à chaque image sans mesurer le DOM à chaque fois.
Demander à IntersectionObserver le titre « le plus visible » paraît plus simple mais ne fonctionne pas : les titres ne font que quelques pixels de haut, donc chaque titre à l’intérieur de la bande de l’observateur rapporte le même intersectionRatio, et les titres qui quittent la bande ne déclenchent aucun callback. La mise en évidence se retrouve alors sur le titre qui figurait dans le dernier lot de callbacks.
Deux détails comptent pour le confort. Une entrée cliquée reste active pendant l’animation du défilement fluide, au lieu de faire clignoter chaque titre traversé en chemin. Et maintenir l’entrée active visible dans une longue table des matières ajuste le propre scrollTop du conteneur de la table des matières — scrollIntoView() remonterait et ferait défiler la page, ce qui rétroagirait directement sur le spy.
Configuration (assets/js/modules/navigation/config.js) :
| Clé | Valeur par défaut | Rôle |
|---|---|---|
scrollSpy.offset |
null |
Distance de la ligne de lecture par rapport au haut de la fenêtre. null la déduit de scroll-padding-top ; définissez un nombre pour la fixer. |
scrollSpy.tolerance |
4 |
Marge (px) au niveau de la ligne de lecture et lors de la détection du bas de la page. |
Défilement fluide
Méthode CSS
html {
scroll-behavior: smooth;
}
Méthode JavaScript
document.querySelectorAll('.toc-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href').slice(1);
const target = document.getElementById(targetId);
const headerOffset = 80;
const position = target.offsetTop - headerOffset;
window.scrollTo({
top: position,
behavior: 'smooth'
});
history.pushState(null, '', `#${targetId}`);
});
});
Comportement responsive
Ordinateur
La table des matières apparaît dans la barre latérale droite :
<aside class="d-none d-lg-block">
{% include content/toc.html %}
</aside>
Mobile
Table des matières en offcanvas (voir Table des matières mobile) :
<div class="offcanvas offcanvas-end d-lg-none" id="tocSidebar">
{% include content/toc.html %}
</div>
Accessibilité
Attributs ARIA
<nav id="TableOfContents"
aria-label="Table of contents"
role="navigation">
Navigation au clavier
- Tabulation à travers les liens de la table des matières
- Entrée pour naviguer vers la section
- Le focus se déplace vers le titre
Dépannage
La table des matières ne se génère pas
- Vérifiez que les titres ont des ID
- Vérifiez
toc: truedans le front matter - Assurez-vous d’utiliser le processeur Kramdown
Le Scroll Spy ne fonctionne pas
- Vérifiez que les ID des titres correspondent aux hrefs de la table des matières — le spy résout le
hrefde chaque lien de la table des matières en un titre viaid, de sorte qu’un lien sans élément correspondant est ignoré - Vérifiez la ligne de lecture —
scroll-padding-topsurhtml(ouconfig.scrollSpy.offset) détermine quand un titre devient courant - Vérifiez que rien d’autre ne revendique
#TableOfContents— un second scroll spy (un élémentdata-bs-spy="scroll"le ciblant) se dispute la classe.active
Problèmes de mise en forme
- Vérifiez le positionnement sticky
- Vérifiez le z-index
- Testez le comportement de débordement
Connexes
Voir aussi
- [[Features]]
- [[Mobile TOC Floating Action Button]]
- [[Sidebar Navigation System]]