L'ajout d'un « lien » de retour vers le haut de page n'est pas inutile, surtout pour la navigation sur mobile où les pages sont vite très longues.
C'est un élément pratique qui est fréquemment utilisé par les visiteurs.
Le plus souvent, on trouve un lien masqué qui apparait au défilement et qui pointe sur un identifiant placé en haut de la page (ou simplement un « hash » d'url). Ça fonctionne, mais pas optimal. href="#" est valide en HTML et la spécification le définit comme « haut du document ».
Limites en pratique :
- Focus : le comportement du point de départ de la navigation séquentielle après activation varie selon les navigateurs. Si le focus reste sur le lien en bas, le prochain Touche clavier Tabulation ⇥ (tabulation au clavier) part du pied de page, ce qui pose un risque WCAG 2.4.3 (ordre de focus). À tester sur Firefox, Chrome et Safari avec un clavier. Sur macOS aves ces trois navigateurs, le lien « simple » ne fonctionne pas.
- Historique :
#ajoute une entrée d’historique et laisse un#dans l’URL. <base href>: s’il existe,#résout vers l’URL de base et non la page courante.
Pour ces raisons, on ne va pas faire un lien, mais un button.
Un autre point à prendre en compte est de savoir à quel moment cet élément apparait et s'il doit apparaitre.
L'idéal est de pouvoir automatiser l'ajout, l'apparition et la disparition de ce bouton. Comment ?
Avec un tout petit Javascript. C'est du natif, sans dépendance. Et totalement conforme pour l'accessibilité.
Le concept
L'idée est d'adapter l'apparition du bouton en dépendance de la hauteur de la fenêtre d'affichage.
- Arbitrairement, la hauteur du document (
rootouhtml) est limitée à100vh. Le corps de la page garde sa hauteur naturelle. - L'API IntersectionObserver va se charger de détecter à quel moment le
rootsort de la fenêtre et gérer l'attributdisableddu bouton. - Les styles vont simplement s'appliquer sur le bouton.
Exit l'observation du scroll !
Le script
Taille brute : 12,21 kilo-octets (~4,29 kilo-octets gzip)
/**
* @file Bouton de retour en haut de page, injecté dynamiquement et accessible.
*
* Principe : le root est limité à 100dvh pendant que le body garde sa hauteur naturelle.
* Un IntersectionObserver détecte la sortie du root du viewport et pilote les attributs
* `disabled` et `hidden` du bouton. Aucun écouteur de scroll.
*/
/**
* Libellés internationalisables. Les valeurs absentes reçoivent un défaut en français.
* @typedef {Object} BackTopI18n
* @property {string} [scroll_wrapper_label] Nom accessible de la région qui contient le bouton.
* @property {string} [button_scroll_top] Nom accessible du bouton.
*/
/**
* Entrée de cache des styles, sérialisée en JSON dans le localStorage.
* @typedef {Object} StoredStyles
* @property {number} timestamp Date de mise en cache, en millisecondes (Date.now()).
* @property {string} css Contenu de la feuille de style injectée.
*/
(() => {
'use strict';
/**
* Active la mesure de performance : repères dans la piste Timings de DevTools et durée en console.
* true en développement, false en production.
* @type {boolean}
*/
const PERF = true;
/**
* Nom du repère et de la mesure de performance du script global.
* @type {string}
*/
const perfId = 'Exécution backTop-total';
// Début de mesure du script global, avant toute déclaration
PERF && performance.mark(`${perfId}:start`);
/**
* Élément racine du document : cible de l'IntersectionObserver et de la reprise de focus.
* @type {HTMLElement}
*/
const root = document.documentElement;
/**
* Active la mise en cache des styles. false en local, true en production.
* @type {boolean}
*/
const CACHE = true;
/**
* Durée de validité du cache des styles, en millisecondes (7 jours, 0 si le cache est désactivé).
* @type {number}
*/
const CACHE_DURATION = CACHE ? 7 * 86400000 : 0;
/**
* Identifiant de l'élément `<style>` injecté, également utilisé comme clé de localStorage.
* @type {string}
*/
const stylesId = 'css-backTop';
/**
* Injecte la feuille de style du bouton dans le `<head>`.
* Les styles sont lus depuis le localStorage s'ils sont encore valides, sinon régénérés puis
* stockés avec un horodatage. L'injection n'a lieu qu'une fois par document.
*
* @function injectStyles
* @memberof window
* @returns {void}
*/
window.injectStyles = () => {
/** @type {string|null} */
const storedStyles = localStorage.getItem(`${stylesId}`);
const now = Date.now();
if (storedStyles) {
/** @type {StoredStyles} */
const {timestamp, css} = JSON.parse(storedStyles);
// Vérifier si le style est expiré
if (now - timestamp < CACHE_DURATION) {
// Éviter une double injection si la fonction est rappelée
if (!document.head.querySelector(`#${stylesId}`)) {
document.head.appendChild(Object.assign(document.createElement('style'), {
id: `${stylesId}`,
textContent: css
}));
}
return;
}
}
/**
* Feuille de style du bouton. Les fonctions rem() sont résolues au build (PostCSS).
* - Le root passe à 100dvh pour que l'IntersectionObserver détecte la sortie du viewport.
* - Le bouton est masqué par défaut (opacity et visibility), `disabled` et `hidden` le pilotent.
* - Le picto est un masque CSS coloré par le background-color du ::before.
* @type {string}
*/
const cssContent = `
:root.js--viewport-height {
height: 100dvh;
max-width: 100dvw;
min-height: 100dvh;
color-scheme: dark light;
}
button.button-scroll-top {
--outline-width: 3px;
--svg-mask-button: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="m20.46,17.71l-3.38-3.09v9.81l-2.4.08v-9.65l-3.01,3.09-1.66-1.55,4.25-4.41c.86-.88,2.29-.91,3.2-.08h0l4.53,4.14-1.53,1.67Zm.78-7.89h-10.47c-.41,0-.76-.32-.76-.72,0-.02,0-.04,0-.06v-.77c-.03-.4.29-.75.7-.77.02,0,.04,0,.06,0h10.47c.41,0,.76.32.76.72,0-.02,0-.04,0-.06v.85c-.02.39-.35.7-.76.7Z"/></svg>');
background-color: CanvasText;
bottom: var(--half-gutter, 2.5rem);
@media (forced-colors: none) {
border: none;
}
@media (prefers-reduced-motion: no-preference) {
:root:not(.no-motion) & {
/* visibility est animée avec l'opacité : visible pendant tout le fondu, hidden à la fin */
transition: opacity .5s, visibility .5s;
}
}
border-radius: var(--button-height, 2.5rem);
color: Canvas;
height: var(--button-height, 2.5rem);
opacity: 0;
outline: var(--outline-width) solid Canvas;
outline-offset: var(--outline-width);
box-shadow: 0 0 0 var(--outline-width) CanvasText;
position: fixed;
right: var(--fixed-position, 2.5rem);
width: var(--button-height, 2.5rem);
&::before {
background-color: Canvas;
content: "";
inset: 0;
-webkit-mask-image: var(--svg-mask-button);
-webkit-mask-position: center center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 75% 75%;
mask-image: var(--svg-mask-button);
mask-position: center center;
mask-repeat: no-repeat;
mask-size: 75% 75%;
/* Pseudo-élément purement décoratif */
pointer-events: none;
position: absolute;
}
&:focus {
outline: var(--outline-width) solid CanvasText !important;
outline-offset: var(--outline-width) !important;
}
@media (any-hover: hover) {
&[disabled] {
pointer-events: none;
}
&:not([disabled]) {
cursor: pointer;
}
&:hover {
outline: var(--outline-width) solid CanvasText;
outline-offset: var(--outline-width);
box-shadow: 0 0 0 var(--outline-width) Canvas;
}
}
&:not([disabled]) {
opacity: 1;
}
/* Annule le display: none de la feuille UA pour conserver la transition, visibility prend le relais */
&[hidden] {
display: block;
visibility: hidden;
}
}
`;
// Stocker le style et l'horodatage dans localStorage
localStorage.setItem(`${stylesId}`, JSON.stringify(
/** @type {StoredStyles} */ ({timestamp: now, css: cssContent})
));
// Ajout du style dans <head>
document.head.appendChild(Object.assign(document.createElement('style'), {
id: `${stylesId}`,
textContent: cssContent
}));
};
/**
* Initialise le bouton de retour en haut :
* - injecte les styles,
* - crée le bouton et sa région s'ils sont absents du HTML,
* - observe le root pour activer ou masquer le bouton,
* - reprend le focus sur le root au clic,
* - se synchronise avec la détection de navigation clavier du site si elle existe.
*
* Ne fait rien si IntersectionObserver n'est pas disponible.
*
* @function scrollObserver
* @memberof window
* @returns {void}
*/
window.scrollObserver = () => {
/**
* Vérification de la compatibilité avec IntersectionObserver
*/
if (!('IntersectionObserver' in window)) {
return;
}
injectStyles();
/**
* Bouton existant dans le HTML, ou créé dynamiquement ci-dessous.
* @type {HTMLButtonElement|null}
*/
let button = document.querySelector('.button-scroll-top');
/**
* Région qui contient le bouton (existante ou créée dynamiquement).
* Pourquoi un wrapper ? Parce qu'on ne met pas d'élément qui se balade tout seul sans region dans la page.
* @type {HTMLElement|null}
*/
let regionWrapper = document.querySelector('.region-scroll-wrapper');
/*
Vérification et création dynamique du bouton si absent
*/
if (!button) {
button = document.createElement('button');
button.className = 'button-scroll-top';
button.type = 'button';
// Désactivé et masqué par défaut, l'IntersectionObserver prend ensuite le relais
button.toggleAttribute('disabled');
button.toggleAttribute('hidden');
// Raccourci clavier déclaré, implémenté côté site
button.setAttribute('aria-keyshortcuts', 't');
/*
Vérification de l'objet i18n et de son contenu
*/
/** @type {BackTopI18n} */
window.i18n ??= {};
window.i18n.scroll_wrapper_label ??= "Contenu additionnel de pied de page";
window.i18n.button_scroll_top ??= "Vous êtes en fin de page. Revenir au début.";
button.setAttribute('aria-label', window.i18n.button_scroll_top);
/*
Déterminer où insérer le bouton : dans un <div role="region"> tout en bas
*/
regionWrapper = document.createElement('div');
regionWrapper.className = 'region-scroll-wrapper';
regionWrapper.setAttribute('role', 'region');
regionWrapper.setAttribute('aria-label', window.i18n.scroll_wrapper_label);
regionWrapper.appendChild(button);
regionWrapper.style.position = 'fixed';
/*
Application du z-index au wrapper du bouton : fonction du site si elle existe,
sinon plus grand z-index du body + 1
*/
regionWrapper.style.zIndex = (window.getMaxZIndex?.() ?? (() => {
const elements = [...document.querySelectorAll(':scope body *')].filter(el => getComputedStyle(el).zIndex !== 'auto');
return Math.max(...elements.map(el => Number(getComputedStyle(el).zIndex) || 0), 0);
})()) + 1;
document.body.appendChild(regionWrapper);
}
// Limite la hauteur du root à 100dvh (voir styles injectés)
root.classList.add('js--viewport-height');
/**
* Instance d'IntersectionObserver sur le root.
* Root dans le viewport : bouton désactivé et masqué. Root sorti : bouton actif et visible.
* @param {IntersectionObserverEntry[]} entries Entrées observées (une seule, le root).
* @returns {void}
*/
new IntersectionObserver(([entry]) => {
/**
* Gestion des attributs "disabled" et "hidden" et de l'état intersecting
*/
if (entry.isIntersecting) {
root.classList.remove('back-top-enabled');
button.setAttribute('disabled', '');
button.setAttribute('hidden', '');
} else {
root.classList.add('back-top-enabled');
button.removeAttribute('disabled');
button.removeAttribute('hidden');
}
// État exposé pour le CSS et pour la détection de navigation clavier
root.setAttribute('data-intersecting', entry.isIntersecting.toString());
}).observe(root);
/**
* Événement de clic sur le bouton : reprise du focus sur le root.
* Le tabindex est temporaire, il rend le root focusable le temps du focus().
* @listens click
* @returns {void}
*/
button.addEventListener('click', () => {
root.setAttribute('tabindex', '-1');
root.focus();
root.removeAttribute('tabindex');
});
/**
* Fonction interne au site qui détecte la navigation clavier.
* `hidden` suit `disabled`, comme dans l'IntersectionObserver.
* @listens document#userNavigationChange Événement personnalisé émis par le site.
* @returns {boolean} Nouvel état commun de `disabled` et `hidden`.
*/
window.dispatchUserNavigationChange instanceof Function &&
document.addEventListener('userNavigationChange', () =>
button.hidden = button.disabled = root.classList.contains('use-keyboard') && root.dataset.intersecting === 'false');
};
/* Initialisation */
scrollObserver();
// Fin de mesure : sans second repère, measure() s'arrête à l'instant présent. Durée convertie de ms en s
PERF && console.log(`${perfId} : ${(performance.measure(perfId, `${perfId}:start`).duration / 1000).toFixed(3)} s`);
})();
Télécharger kortic-backTopAnchor.js
Installation
Première option, placer directement un button dans la page, de préférence comme dernier élément du footer.
Code attendu :
<button type="button" class="button-scroll-top" disabled>
<span class="screen-readers">Revenir en haut de la page</span>
</button>
Les styles CSS
Comme on aime bien faire des choses farfelues, le script intègre les styles. Si, si, on ose.
Mais pour ne pas dégrader les performances, ces styles sont enregistrés en localStorage, et c'est là que ça devient amusant !
L'avantage de cette méthode est que le script et la mise en forme sont regroupés et adaptables facilement.
Note : le pictogramme est intégré nativement et le localStorage est rafraichi au bout de 7 jours. Configurable, bien entendu.
Accessibilité
Le bouton gère la reprise de focus sur le :root afin de pouvoir reprendre la navigation clavier en haut de page.
Il est internationalisé si besoin, à adapter à votre contexte.
Tester
- Copier le Javascript et l'insérer dans le
headde page, ou bien en fin de code. - Copier le code Javascript, ouvrir un site internet, ouvrir la console, coller le code et hop !
Normalement, ça devrait fonctionner.
That's all Folks !